From c37436b5715751fe9feacebd1464bc039b406357 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 24 Jun 2026 17:03:55 +0100 Subject: [PATCH 01/33] clean rebased merge of marc/aa2 --- Directory.Build.props | 2 +- docs/ActiveActive.md | 650 ++++++++++ docs/exp/SER004.md | 2 + docs/exp/SER005.md | 2 + docs/exp/SER007.md | 15 + docs/index.md | 1 + src/RESPite/Shared/Experiments.cs | 1 + .../ChannelMessageQueue.cs | 24 +- .../ConfigurationOptions.cs | 7 + .../ConnectionMultiplexer.cs | 30 +- src/StackExchange.Redis/FrameworkShims.cs | 1 + .../HealthCheck.ConnectedProbe.cs | 24 + .../HealthCheck.Execute.cs | 190 +++ .../HealthCheck.HealthCheckProbe.cs | 79 ++ .../HealthCheck.HealthCheckProbeContext.cs | 41 + .../HealthCheck.HealthCheckProbePolicy.cs | 102 ++ .../HealthCheck.PingProbe.cs | 27 + .../HealthCheck.StringSetProbe.cs | 59 + src/StackExchange.Redis/HealthCheck.cs | 122 ++ .../Interfaces/IConnectionGroup.cs | 124 ++ src/StackExchange.Redis/Interfaces/IServer.cs | 5 + .../MultiGroupDatabase.Async.cs | 66 + .../MultiGroupDatabase.Geo.Async.cs | 52 + .../MultiGroupDatabase.Geo.cs | 50 + .../MultiGroupDatabase.Hashes.Async.cs | 128 ++ .../MultiGroupDatabase.Hashes.cs | 130 ++ .../MultiGroupDatabase.HyperLogLog.Async.cs | 25 + .../MultiGroupDatabase.HyperLogLog.cs | 23 + .../MultiGroupDatabase.Keys.Async.cs | 80 ++ .../MultiGroupDatabase.Keys.cs | 79 ++ .../MultiGroupDatabase.Lists.Async.cs | 79 ++ .../MultiGroupDatabase.Lists.cs | 77 ++ .../MultiGroupDatabase.Sets.Async.cs | 64 + .../MultiGroupDatabase.Sets.cs | 65 + .../MultiGroupDatabase.SortedSets.Async.cs | 127 ++ .../MultiGroupDatabase.SortedSets.cs | 130 ++ .../MultiGroupDatabase.Streams.Async.cs | 137 ++ .../MultiGroupDatabase.Streams.cs | 136 ++ .../MultiGroupDatabase.Strings.Async.cs | 119 ++ .../MultiGroupDatabase.Strings.cs | 118 ++ .../MultiGroupDatabase.VectorSets.Async.cs | 55 + .../MultiGroupDatabase.VectorSets.cs | 53 + src/StackExchange.Redis/MultiGroupDatabase.cs | 95 ++ .../MultiGroupMultiplexer.cs | 1136 +++++++++++++++++ src/StackExchange.Redis/MultiGroupOptions.cs | 49 + .../MultiGroupSubscriber.Tracking.cs | 246 ++++ .../MultiGroupSubscriber.cs | 41 + .../PublicAPI/PublicAPI.Unshipped.txt | 88 +- src/StackExchange.Redis/RedisServer.cs | 12 + src/StackExchange.Redis/ResultProcessor.cs | 10 +- src/StackExchange.Redis/ServerEndPoint.cs | 14 + .../ServerSelectionStrategy.HashTags.cs | 86 ++ .../ServerSelectionStrategy.cs | 26 +- .../StackExchange.Redis.csproj | 9 + src/StackExchange.Redis/TaskExtensions.cs | 5 + .../ActiveActiveIntegrationTests.cs | 44 + .../HashTagUnitTests.cs | 29 + .../HealthCheckPolicyUnitTests.cs | 108 ++ .../Helpers/TestConfig.cs | 1 + .../InProcessTestServer.cs | 9 + .../MultiGroupTests/BasicMultiGroupTests.cs | 385 ++++++ .../RedisTestConfig.json | 4 + .../StackExchange.Redis.Tests.csproj | 4 +- .../MemoryCacheRedisServer.cs | 20 +- .../StackExchange.Redis.Server/RedisServer.cs | 40 +- toys/StackExchange.Redis.Server/RespServer.cs | 4 + 66 files changed, 5732 insertions(+), 34 deletions(-) create mode 100644 docs/ActiveActive.md create mode 100644 docs/exp/SER007.md create mode 100644 src/StackExchange.Redis/FrameworkShims.cs create mode 100644 src/StackExchange.Redis/HealthCheck.ConnectedProbe.cs create mode 100644 src/StackExchange.Redis/HealthCheck.Execute.cs create mode 100644 src/StackExchange.Redis/HealthCheck.HealthCheckProbe.cs create mode 100644 src/StackExchange.Redis/HealthCheck.HealthCheckProbeContext.cs create mode 100644 src/StackExchange.Redis/HealthCheck.HealthCheckProbePolicy.cs create mode 100644 src/StackExchange.Redis/HealthCheck.PingProbe.cs create mode 100644 src/StackExchange.Redis/HealthCheck.StringSetProbe.cs create mode 100644 src/StackExchange.Redis/HealthCheck.cs create mode 100644 src/StackExchange.Redis/Interfaces/IConnectionGroup.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.Async.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.Geo.Async.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.Geo.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.Hashes.Async.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.Hashes.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.HyperLogLog.Async.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.HyperLogLog.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.Keys.Async.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.Keys.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.Lists.Async.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.Lists.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.Sets.Async.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.Sets.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.SortedSets.Async.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.SortedSets.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.Streams.Async.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.Streams.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.Strings.Async.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.Strings.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.VectorSets.Async.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.VectorSets.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.cs create mode 100644 src/StackExchange.Redis/MultiGroupMultiplexer.cs create mode 100644 src/StackExchange.Redis/MultiGroupOptions.cs create mode 100644 src/StackExchange.Redis/MultiGroupSubscriber.Tracking.cs create mode 100644 src/StackExchange.Redis/MultiGroupSubscriber.cs create mode 100644 src/StackExchange.Redis/ServerSelectionStrategy.HashTags.cs create mode 100644 tests/StackExchange.Redis.Tests/ActiveActiveIntegrationTests.cs create mode 100644 tests/StackExchange.Redis.Tests/HashTagUnitTests.cs create mode 100644 tests/StackExchange.Redis.Tests/HealthCheckPolicyUnitTests.cs create mode 100644 tests/StackExchange.Redis.Tests/MultiGroupTests/BasicMultiGroupTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index 7d2e9c6c5c..058d2be9ec 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -10,7 +10,7 @@ true $(MSBuildThisFileDirectory)Shared.ruleset NETSDK1069 - $(NoWarn);NU5105;NU1507;SER001;SER002;SER003;SER004;SER005;SER006 + $(NoWarn);NU5105;NU1507;SER001;SER002;SER003;SER004;SER005;SER006;SER007 https://stackexchange.github.io/StackExchange.Redis/ReleaseNotes https://stackexchange.github.io/StackExchange.Redis/ MIT diff --git a/docs/ActiveActive.md b/docs/ActiveActive.md new file mode 100644 index 0000000000..1217f35fad --- /dev/null +++ b/docs/ActiveActive.md @@ -0,0 +1,650 @@ +# Active:Active + +## Overview + +The Active:Active feature provides automatic failover and intelligent routing across multiple Redis deployments. The library +automatically selects the best available endpoint based on: + +1. **Availability** - Connected endpoints are always preferred over disconnected ones +2. **Weight** - User-defined preference values (higher is better) +3. **Latency** - Measured response times (lower is better) + +This enables scenarios such as: +- Multi-datacenter deployments with automatic failover +- Geographic routing to the nearest Redis instance +- Graceful degradation during maintenance or outages +- Load distribution across multiple Redis clusters + +## Basic Usage + +### Connecting to Multiple Groups + +To create an Active:Active connection, use `ConnectionMultiplexer.ConnectGroupAsync()` with an array of `ConnectionGroupMember` instances: + +```csharp +using StackExchange.Redis; + +// Define your Redis endpoints +ConnectionGroupMember[] members = [ + new("us-east.redis.example.com:6379", name: "US East"), + new("us-west.redis.example.com:6379", name: "US West"), + new("eu-central.redis.example.com:6379", name: "EU Central") +]; + +// Connect to all members +await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members); + +// Use the connection normally +var db = conn.GetDatabase(); +await db.StringSetAsync("mykey", "myvalue"); +var value = await db.StringGetAsync("mykey"); +``` + +### Using ConfigurationOptions + +You can also use `ConfigurationOptions` for more advanced configuration: + +```csharp +var eastConfig = new ConfigurationOptions +{ + EndPoints = { "us-east-1.redis.example.com:6379", "us-east-2.redis.example.com:6379" }, + Password = "your-password", + Ssl = true, +}; + +var westConfig = new ConfigurationOptions +{ + EndPoints = { "us-west-1.redis.example.com:6379", "us-west-2.redis.example.com:6379" }, + Password = "another-different-password", + Ssl = true, +}; + +ConnectionGroupMember[] members = [ + new(eastConfig, name: "US East"), + new(westConfig, name: "US West") +]; + +await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members); +``` + +## Configuring Weights + +Weights allow you to express preference for specific endpoints. Higher weights are preferred when multiple endpoints are available: + +```csharp +ConnectionGroupMember[] members = [ + new("local-dc.redis.example.com:6379") { Weight = 10 }, // Strongly preferred + new("nearby-dc.redis.example.com:6379") { Weight = 5 }, // Moderately preferred + new("remote-dc.redis.example.com:6379") { Weight = 1 } // Fallback option +]; + +await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members); +``` + +Weights can be adjusted dynamically: + +```csharp +// Adjust weight based on runtime conditions +members[0].Weight = 1; // Reduce preference for local DC +members[2].Weight = 10; // Increase preference for remote DC +``` + +## Working with IDatabase + +The `IDatabase` interface works transparently with Active:Active connections. All operations are automatically routed to the currently selected endpoint: + +```csharp +var db = conn.GetDatabase(); + +// String operations +await db.StringSetAsync("user:1:name", "Alice"); +var name = await db.StringGetAsync("user:1:name"); + +// Hash operations +await db.HashSetAsync("user:1", new HashEntry[] { + new("name", "Alice"), + new("email", "alice@example.com") +}); + +// List operations +await db.ListRightPushAsync("queue:tasks", "task1"); +var task = await db.ListLeftPopAsync("queue:tasks"); + +// Set operations +await db.SetAddAsync("tags", new RedisValue[] { "redis", "cache", "database" }); +var members = await db.SetMembersAsync("tags"); + +// Sorted set operations +await db.SortedSetAddAsync("leaderboard", "player1", 100); +var rank = await db.SortedSetRankAsync("leaderboard", "player1"); + +// Transactions +var tran = db.CreateTransaction(); +var t1 = tran.StringSetAsync("key1", "value1"); +var t2 = tran.StringSetAsync("key2", "value2"); +if (await tran.ExecuteAsync()) +{ + await t1; + await t2; +} + +// Batches +var batch = db.CreateBatch(); +var b1 = batch.StringSetAsync("key1", "value1"); +var b2 = batch.StringSetAsync("key2", "value2"); +batch.Execute(); +await Task.WhenAll(b1, b2); +``` + +## Working with ISubscriber + +Pub/Sub operations work across all connected endpoints. When you subscribe to a channel, the subscription is established against *all* endpoints (for immediate pickup +during failover events), and received messages are filtered in the library so only the messages for the *active* endpoint are observed. Message publishing +occurs only to the *active* endpoint. The effect of this is that pub/sub works transparently as though +you were only talking to the *active* endpoint: + +```csharp +var subscriber = conn.GetSubscriber(); + +// Subscribe to a channel +await subscriber.SubscribeAsync(RedisChannel.Literal("notifications"), (channel, message) => +{ + Console.WriteLine($"Received: {message}"); +}); + +// Publish to a channel +await subscriber.PublishAsync(RedisChannel.Literal("notifications"), "Hello, World!"); + +// Pattern-based subscriptions +await subscriber.SubscribeAsync(RedisChannel.Pattern("events:*"), (channel, message) => +{ + Console.WriteLine($"Event on {channel}: {message}"); +}); + +// Unsubscribe +await subscriber.UnsubscribeAsync(RedisChannel.Literal("notifications")); +``` + +**Note:** When the active endpoint changes (due to failover), subscriptions are automatically re-established on the new endpoint. + +## Monitoring Connection Changes + +You can monitor when the active connection changes using the `ConnectionChanged` event: + +```csharp +conn.ConnectionChanged += (sender, args) => +{ + Console.WriteLine($"Connection changed: {args.Type}"); + Console.WriteLine($"Previous: {args.PreviousGroup?.Name ?? "(none)"}"); + Console.WriteLine($"Current: {args.Group.Name}"); +}; +``` + +## Monitoring Member Status + +Each `ConnectionGroupMember` provides status information: + +```csharp +foreach (var member in conn.GetMembers()) +{ + Console.WriteLine($"{member.Name}:"); + Console.WriteLine($" Connected: {member.IsConnected}"); + Console.WriteLine($" Weight: {member.Weight}"); + Console.WriteLine($" Latency: {member.Latency}"); +} +``` + +These are the same instances that were passed into `ConnectGroupAsync`. + +## Health Checks + +The Active:Active feature includes configurable health checking to monitor the health of all endpoints and automatically route traffic away from unhealthy instances. + +### Basic Health Check Configuration + +Health checks are configured globally for all members using the `MultiGroupOptions` parameter: + +```csharp +var healthCheck = new HealthCheck +{ + Interval = TimeSpan.FromSeconds(10), // How often to check health + ProbeCount = 3, // Maximum number of probe attempts per check + ProbeTimeout = TimeSpan.FromSeconds(2), // Timeout for each probe attempt + ProbeInterval = TimeSpan.FromSeconds(1), // Delay between failed probes + Probe = HealthCheckProbe.Ping, // Which probe type to use + ProbePolicy = HealthCheckProbePolicy.AnySuccess // Evaluation policy +}; + +var options = new MultiGroupOptions +{ + DefaultHealthCheck = healthCheck +}; + +ConnectionGroupMember[] members = [ + new("us-east.redis.example.com:6379", name: "US East") { Weight = 10 }, + new("us-west.redis.example.com:6379", name: "US West") { Weight = 5 } +]; + +await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members, options); +``` + +### Using Default Health Checks + +If you don't specify a health check, the system uses sensible defaults: + +```csharp +// Uses default health check settings +await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members); + +// Equivalent to: +var options = new MultiGroupOptions +{ + DefaultHealthCheck = HealthCheck.Default +}; +await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members, options); +``` + +You can also clone and customize the default: + +```csharp +var customHealthCheck = HealthCheck.Default.Clone(); +customHealthCheck.Interval = TimeSpan.FromSeconds(5); +customHealthCheck.ProbeCount = 5; + +var options = new MultiGroupOptions +{ + DefaultHealthCheck = customHealthCheck +}; +``` + +### Health Check Properties + +The `HealthCheck` class provides several configurable properties: + +| Property | Default | Description | +|----------|---------|-------------| +| `Interval` | 10 seconds | How frequently health checks are performed | +| `ProbeCount` | 3 | Number of probe operations to perform per health check | +| `ProbeTimeout` | 2 seconds | Maximum time allowed for an individual probe to complete | +| `ProbeInterval` | 1 second | Delay between consecutive failed probes | +| `Probe` | `Ping` | The probe operation to execute | +| `ProbePolicy` | `AnySuccess` | Policy for evaluating multiple probe results | + +### Built-in Probes + +StackExchange.Redis provides several built-in health check probes: + +#### HealthCheckProbe.Ping + +The simplest probe that executes a `PING` command against the server: + +```csharp +var healthCheck = new HealthCheck +{ + Probe = HealthCheckProbe.Ping +}; +``` + +This is the default and recommended probe for most scenarios as it's lightweight and tests basic connectivity. + +#### HealthCheckProbe.IsConnected + +Checks the connection status without sending any commands: + +```csharp +var healthCheck = new HealthCheck +{ + Probe = HealthCheckProbe.IsConnected +}; +``` + +This is even more lightweight than `Ping` but only verifies the socket connection, not Redis responsiveness. + +#### HealthCheckProbe.StringSet + +Performs a write operation to verify read/write capability: + +```csharp +var healthCheck = new HealthCheck +{ + Probe = HealthCheckProbe.StringSet +}; +``` + +This probe writes a random value to a health check key and verifies it can be retrieved. It's more comprehensive but has higher overhead than `Ping`. Note that this probe automatically skips replica servers. + +### Health Check Policies + +The probe policy determines how multiple probe results are evaluated to determine overall health: + +#### HealthCheckProbePolicy.AnySuccess (Default) + +The health check passes if **any** probe succeeds. This provides the most lenient evaluation: + +```csharp +var healthCheck = new HealthCheck +{ + ProbeCount = 3, + ProbePolicy = HealthCheckProbePolicy.AnySuccess +}; +// Healthy if 1 or more of 3 probes succeed +``` + +#### HealthCheckProbePolicy.AllSuccess + +The health check passes only if **all** probes succeed. This provides the strictest evaluation: + +```csharp +var healthCheck = new HealthCheck +{ + ProbeCount = 3, + ProbePolicy = HealthCheckProbePolicy.AllSuccess +}; +// Healthy only if all 3 probes succeed +``` + +#### HealthCheckProbePolicy.MajoritySuccess + +The health check passes if a **majority** of probes succeed: + +```csharp +var healthCheck = new HealthCheck +{ + ProbeCount = 3, + ProbePolicy = HealthCheckProbePolicy.MajoritySuccess +}; +// Healthy if 2 or more of probes succeed +``` + +### Custom Health Check Probes + +You can implement custom health check logic by extending `HealthCheckProbe`. Note that care must be used +if the probe involves talking to data via a `RedisKey`, as on "cluster" configurations, it must be ensured that the +key used resolves to the correct server; for this purpose, the `server.InventKey` method can be used: + +```csharp +public abstract class CustomProbe : HealthCheckProbe +{ + public override Task CheckHealthAsync(HealthCheck healthCheck, IServer server) + { + // create a random key that routes to the correct server, using + // the specified prefix + RedisKey key = server.InventKey("health-check/"); + // ... + } +} +```` + +Or more conveniently, the key-specific `KeyWriteHealthCheckProbe` encapsulates this logic: + +```csharp +public class CustomWriteProbe : KeyWriteHealthCheckProbe +{ + public override async Task CheckHealthAsync( + HealthCheck healthCheck, + IDatabaseAsync database, + RedisKey key) + { + try + { + var value = Guid.NewGuid().ToString(); + await database.StringSetAsync(key, value, expiry: healthCheck.ProbeTimeout); + bool isMatch = value == await database.StringGetAsync(key); + + return isMatch ? HealthCheckResult.Healthy : HealthCheckResult.Unhealthy; + } + catch + { + return HealthCheckResult.Unhealthy; + } + } +} +``` + +### Custom Probe Policies + +In addition to the inbuilt policies, custom policies can be implemented by extending `HealthCheckProbePolicy`. +By checking the properties of the `HealthCheckProbeContext` parameter, your policy can make a determination +about the health of the server - returning `HealthCheckResult.Healthy` or `HealthCheckResult.Unhealthy` as +appropriate. If you return `HealthCheckResult.Inconclusive`, the health check will continue with additional probes. + +#### Example: Require at Least N Successes + +This example demonstrates a policy that requires at least a specified number of successful probes before declaring the endpoint healthy: + +```csharp +public class AtLeastPolicy(int requiredSuccesses) : HealthCheckProbePolicy +{ + public override HealthCheckResult Evaluate(in HealthCheckProbeContext context) + { + // Success if we have at least the required number of successful probes + if (context.Success >= requiredSuccesses) return HealthCheckResult.Healthy; + + // If no more probes remaining, we haven't met our threshold; otherwise: keep trying + return context.Remaining == 0 ? HealthCheckResult.Unhealthy : HealthCheckResult.Inconclusive; + } +} + +// Use the custom policy requiring at least 2 successes +var healthCheck = new HealthCheck +{ + ProbeCount = 5, // Need enough probes to allow for the required successes + ProbePolicy = new AtLeastPolicy(2) +}; + +var options = new MultiGroupOptions +{ + DefaultHealthCheck = healthCheck +}; +``` + +This policy ensures that transient successes don't immediately mark an endpoint as healthy. It requires at least the specified number of successful probes, which provides better confidence in the endpoint's stability while still being more lenient than `AllSuccess`. + +### Health Check Behavior + +When a health check fails for a member: +- The member's `IsConnected` property reflects the unhealthy state +- Traffic is automatically routed to other healthy members based on weight and latency +- The system continues to perform health checks on the unhealthy member +- Once the member recovers and passes health checks, traffic automatically resumes + +### Best Practices + +1. **Choose appropriate probe types**: Use `Ping` for most scenarios; use `StringSet` when you need to verify write capability +2. **Balance probe frequency**: More frequent checks provide faster failover but increase load on your Redis servers +3. **Match policy to requirements**: Use `AnySuccess` for resilience, `AllSuccess` for strict validation, `MajoritySuccess` for balance +4. **Increase probe count for critical systems**: More probes with `MajoritySuccess` reduces false positives from transient failures +5. **Set reasonable timeouts**: Ensure `ProbeTimeout` accounts for network latency to your Redis servers +6. **Consider replica behavior**: Write-based probes automatically skip replicas to avoid false negatives + +## Manual Failover + +In some scenarios, you may need to manually control which member is actively serving traffic, overriding the automatic selection based on weight and latency. The `TryFailoverTo` method allows you to explicitly switch to a specific member or restore automatic selection. + +### Basic Failover to a Specific Member + +```csharp +await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members); + +// Get the members to find the one you want to fail over to +var groupMembers = conn.GetMembers(); +var targetMember = groupMembers.FirstOrDefault(m => m.Name == "US West"); + +if (targetMember != null) +{ + // Attempt to fail over to the specified member + bool success = conn.TryFailoverTo(targetMember); + + if (success) + { + Console.WriteLine($"Successfully failed over to {targetMember.Name}"); + } + else + { + Console.WriteLine($"Failed to fail over to {targetMember.Name} (member may be disconnected)"); + } +} +``` + +### Restore Automatic Selection + +To remove an explicit failover and return to automatic member selection based on weight and latency: + +```csharp +// Pass null to remove the explicit failover +bool hadExplicitFailover = conn.TryFailoverTo(null); + +if (hadExplicitFailover) +{ + Console.WriteLine("Removed explicit failover, now using automatic selection"); +} +else +{ + Console.WriteLine("No explicit failover was active"); +} +``` + +### Failover Behavior + +The `TryFailoverTo` method has the following behavior: + +- **Returns `true`**: The failover was successful and the specified member is now active (or an explicit override was successfully removed) +- **Returns `false`**: The failover failed because: + - The member is not connected + - The member is not part of this connection group + +When an explicit failover is active: +- The specified member will be preferred for all traffic +- Weight and latency are ignored for member selection +- If the explicitly selected member becomes unavailable, the system automatically falls back to other connected members +- Health checks continue to run on all members + +### Example: Maintenance Mode + +This is particularly useful when performing maintenance on one region and you want to temporarily route all traffic to another: + +```csharp +var members = new ConnectionGroupMember[] +{ + new("us-east.redis.example.com:6379", name: "US East") { Weight = 100 }, + new("us-west.redis.example.com:6379", name: "US West") { Weight = 100 } +}; + +await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members); + +// During maintenance on US East, explicitly route to US West +var westMember = conn.GetMembers().First(m => m.Name == "US West"); +if (conn.TryFailoverTo(westMember)) +{ + Console.WriteLine("Traffic now routed to US West for maintenance"); +} + +// ... perform maintenance on US East ... + +// After maintenance, restore automatic selection +if (conn.TryFailoverTo(null)) +{ + Console.WriteLine("Maintenance complete, automatic selection restored"); +} +``` + +### Example: Monitoring Failover Events + +You can monitor when failovers occur using the `ConnectionChanged` event: + +```csharp +conn.ConnectionChanged += (sender, args) => +{ + if (args.Type == GroupConnectionChangedEventArgs.ChangeType.ActiveChanged) + { + Console.WriteLine($"Active member changed from {args.PreviousGroup?.Name ?? "none"} to {args.Group.Name}"); + } +}; + +// Trigger an explicit failover +var member = conn.GetMembers().First(m => m.Name == "Backup"); +conn.TryFailoverTo(member); +// Event will fire: "Active member changed from Primary to Backup" +``` + +### Important Notes + +1. **Connection Required**: You can only fail over to a member that is currently connected (`IsConnected == true`) +2. **Temporary Override**: The explicit failover persists until: + - You call `TryFailoverTo(null)` to remove it + - The connection group is disposed + - The explicitly selected member becomes disconnected (automatic fallback occurs) +3. **Not Persistent**: Explicit failovers are not persisted across application restarts +4. **Thread-Safe**: `TryFailoverTo` is thread-safe and can be called concurrently with normal operations + +## Dynamic Member Management + +You can add or remove members dynamically using the `IConnectionGroup` interface: + +```csharp +// Cast to IConnectionGroup to access dynamic member management +var group = (IConnectionGroup)conn; + +// Add a new member at runtime +var newMember = new ConnectionGroupMember("new-dc.redis.example.com:6379", name: "New Datacenter") +{ + Weight = 5 +}; +await group.AddAsync(newMember); +Console.WriteLine($"Added {newMember.Name} to the group"); + +// Remove a member +var memberToRemove = members[2]; // Reference to an existing member +if (group.Remove(memberToRemove)) +{ + Console.WriteLine($"Removed {memberToRemove.Name} from the group"); +} +else +{ + Console.WriteLine($"Failed to remove {memberToRemove.Name} - member not found"); +} + +// Check current members +var currentMembers = group.GetMembers(); +Console.WriteLine($"Current member count: {currentMembers.Length}"); +foreach (var member in currentMembers) +{ + Console.WriteLine($" - {member.Name} (Weight: {member.Weight}, Connected: {member.IsConnected})"); +} +``` + +### Adding Members During Maintenance + +Add a new datacenter before removing an old one for zero-downtime migrations: + +```csharp +var group = (IConnectionGroup)conn; + +// Add the new datacenter +var newDC = new ConnectionGroupMember("new-location.redis.example.com:6379", name: "New Location") +{ + Weight = 10 // High weight to prefer the new location +}; +await group.AddAsync(newDC); + +// Wait for the new member to be fully connected and healthy +await Task.Delay(TimeSpan.FromSeconds(5)); + +if (newDC.IsConnected) +{ + Console.WriteLine("New datacenter is online and healthy"); + + // Reduce weight of old datacenter + var oldDC = members[0]; + oldDC.Weight = 1; + + // Wait for traffic to shift + await Task.Delay(TimeSpan.FromSeconds(10)); + + // Remove the old datacenter + if (group.Remove(oldDC)) + { + Console.WriteLine("Old datacenter removed successfully"); + } +} +``` diff --git a/docs/exp/SER004.md b/docs/exp/SER004.md index 91f5d87c40..e1e77968bc 100644 --- a/docs/exp/SER004.md +++ b/docs/exp/SER004.md @@ -4,6 +4,8 @@ RESPite is an experimental library that provides high-performance low-level RESP It is used as the IO core for StackExchange.Redis v3+. You should not (yet) use it directly unless you have a very good reason to do so. +To suppress this message, add the following to your `csproj` file: + ```xml $(NoWarn);SER004 ``` diff --git a/docs/exp/SER005.md b/docs/exp/SER005.md index 03e7b7bb41..6e446fed3f 100644 --- a/docs/exp/SER005.md +++ b/docs/exp/SER005.md @@ -10,6 +10,8 @@ These types are considered slightly more... *mercurial*. We encourage you to use (not just for fun) you might need to update your test code if we tweak something. This should not impact "real" library usage. +To suppress this message, add the following to your `csproj` file: + ```xml $(NoWarn);SER005 ``` diff --git a/docs/exp/SER007.md b/docs/exp/SER007.md new file mode 100644 index 0000000000..7d7f64f5ab --- /dev/null +++ b/docs/exp/SER007.md @@ -0,0 +1,15 @@ +# Active:Active + +This feature is typically used to provide geo-redundant services; please see [full docs](/ActiveActive). + +To suppress this message, add the following to your `csproj` file: + +```xml +$(NoWarn);SER007 +``` + +or more granularly / locally in C#: + +``` c# +#pragma warning disable SER007 +``` \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 69a4cc70f0..c6468534f0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,6 +35,7 @@ Documentation - [Basic Usage](Basics) - getting started and basic usage - [Async Timeouts](AsyncTimeouts) - async timeouts and cancellation - [Configuration](Configuration) - options available when connecting to redis +- [Active:Active](ActiveActive) - connecting to multiple Redis endpoints for high availability - [Pipelines and Multiplexers](PipelinesMultiplexers) - what is a multiplexer? - [Keys, Values and Channels](KeysValues) - discusses the data-types used on the API - [Transactions](Transactions) - how atomic transactions work in redis diff --git a/src/RESPite/Shared/Experiments.cs b/src/RESPite/Shared/Experiments.cs index 1d9a091fab..617934f661 100644 --- a/src/RESPite/Shared/Experiments.cs +++ b/src/RESPite/Shared/Experiments.cs @@ -13,6 +13,7 @@ internal static class Experiments public const string Respite = "SER004"; public const string UnitTesting = "SER005"; public const string Server_8_8 = "SER006"; + public const string ActiveActive = "SER007"; // ReSharper restore InconsistentNaming } } diff --git a/src/StackExchange.Redis/ChannelMessageQueue.cs b/src/StackExchange.Redis/ChannelMessageQueue.cs index 7defc35ccf..4171ed4b48 100644 --- a/src/StackExchange.Redis/ChannelMessageQueue.cs +++ b/src/StackExchange.Redis/ChannelMessageQueue.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; @@ -34,7 +35,7 @@ public sealed class ChannelMessageQueue : IAsyncEnumerable /// public Task Completion => _queue.Reader.Completion; - internal ChannelMessageQueue(in RedisChannel redisChannel, RedisSubscriber parent) + internal ChannelMessageQueue(in RedisChannel redisChannel, RedisSubscriber? parent) { Channel = redisChannel; _parent = parent; @@ -48,8 +49,22 @@ internal ChannelMessageQueue(in RedisChannel redisChannel, RedisSubscriber paren private void Write(in RedisChannel channel, in RedisValue value) { - var writer = _queue.Writer; - writer.TryWrite(new ChannelMessage(this, channel, value)); + try + { + _queue.Writer.TryWrite(new ChannelMessage(this, channel, value)); + } + catch (Exception ex) + { + Debug.WriteLine("pub/sub ChannelWrite.TryWrite failed: " + ex.Message); + } + } + + internal void SynchronizedWrite(in RedisChannel channel, in RedisValue value) + { + lock (this) + { + Write(channel, value); + } } /// @@ -326,4 +341,7 @@ public async IAsyncEnumerator GetAsyncEnumerator(CancellationTok } } #endif + + internal ValueTask WaitToReadAsync(CancellationToken cancellationToken = default) + => _queue.Reader.WaitToReadAsync(cancellationToken); } diff --git a/src/StackExchange.Redis/ConfigurationOptions.cs b/src/StackExchange.Redis/ConfigurationOptions.cs index eef0bde814..87ef467405 100644 --- a/src/StackExchange.Redis/ConfigurationOptions.cs +++ b/src/StackExchange.Redis/ConfigurationOptions.cs @@ -44,6 +44,12 @@ public static int ParseInt32(string key, string value, int minValue = int.MinVal return tmp; } + public static float ParseSingle(string key, string value) + { + if (!Format.TryParseDouble(value, out double tmp)) throw new ArgumentOutOfRangeException(key, $"Keyword '{key}' requires a numeric value; the value '{value}' is not recognised."); + return (float)tmp; + } + internal static bool ParseBoolean(string key, string value) { if (!Format.TryParseBoolean(value, out bool tmp)) throw new ArgumentOutOfRangeException(key, $"Keyword '{key}' requires a boolean value; the value '{value}' is not recognised."); @@ -883,6 +889,7 @@ public string TieBreaker public int WriteBuffer { get => 0; + // ReSharper disable once ValueParameterNotUsed set { } } diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index 20c8576eba..bc618b1842 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -1036,7 +1036,7 @@ public void UnRoot(int token) } } - private void OnHeartbeat() + internal void OnHeartbeat() { try { @@ -1129,7 +1129,7 @@ public IDatabase GetDatabase(int db = -1, object? asyncState = null) } // DB zero is stored separately, since 0-only is a massively common use-case - private const int MaxCachedDatabaseInstance = 16; // 17 items - [0,16] + internal const int MaxCachedDatabaseInstance = 16; // 17 items - [0,16] // Side note: "databases 16" is the default in redis.conf; happy to store one extra to get nice alignment etc private IDatabase? dbCacheZero; private IDatabase[]? dbCacheLow; @@ -1282,6 +1282,8 @@ public long OperationCount } } + internal uint LatencyTicks { get; private set; } = uint.MaxValue; + // note that the RedisChannel->byte[] converter is always direct, so this is not an alloc // (we deal with channels far less frequently, so pay the encoding cost up-front) internal byte[] ChannelPrefix => ((byte[]?)RawConfig.ChannelPrefix) ?? []; @@ -2362,5 +2364,29 @@ private Task[] QuitAllServers() long? IInternalConnectionMultiplexer.GetConnectionId(EndPoint endpoint, ConnectionType type) => GetServerEndPoint(endpoint)?.GetBridge(type)?.ConnectionId; + + internal uint UpdateLatency() + { + var snapshot = GetServerSnapshot(); + uint max = uint.MaxValue; + foreach (var server in snapshot) + { + if (server.IsConnected) + { + var latency = server.LatencyTicks; + if (max is uint.MaxValue || latency > max) + { + max = latency; + } + } + } + + if (max != uint.MaxValue) + { + LatencyTicks = max; + } + + return LatencyTicks; + } } } diff --git a/src/StackExchange.Redis/FrameworkShims.cs b/src/StackExchange.Redis/FrameworkShims.cs new file mode 100644 index 0000000000..5f282702bb --- /dev/null +++ b/src/StackExchange.Redis/FrameworkShims.cs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/StackExchange.Redis/HealthCheck.ConnectedProbe.cs b/src/StackExchange.Redis/HealthCheck.ConnectedProbe.cs new file mode 100644 index 0000000000..0a669324b2 --- /dev/null +++ b/src/StackExchange.Redis/HealthCheck.ConnectedProbe.cs @@ -0,0 +1,24 @@ +using System.Net; +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +public sealed partial class HealthCheck +{ + public partial class HealthCheckProbe + { + /// + /// Report health using the property, without any additional tests. + /// + public static HealthCheckProbe IsConnected => ConnectedProbe.Instance; + } + + private sealed class ConnectedProbe : HealthCheckProbe + { + public static ConnectedProbe Instance { get; } = new(); + private ConnectedProbe() { } + + public override Task CheckHealthAsync(HealthCheck healthCheck, IServer server) + => server.IsConnected ? HealthyTask : UnhealthyTask; + } +} diff --git a/src/StackExchange.Redis/HealthCheck.Execute.cs b/src/StackExchange.Redis/HealthCheck.Execute.cs new file mode 100644 index 0000000000..d228a253ab --- /dev/null +++ b/src/StackExchange.Redis/HealthCheck.Execute.cs @@ -0,0 +1,190 @@ +using System; +using System.Buffers; +using System.Diagnostics; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +public sealed partial class HealthCheck +{ + /// + /// Evaluate the health of the specified multiplexer, by evaluating all endpoints. + /// + public Task CheckHealthAsync(IConnectionMultiplexer multiplexer) + => multiplexer.IsConnected ? CheckHealthCoreAsync(multiplexer) : HealthCheckProbe.UnhealthyTask; + + private async Task CheckHealthCoreAsync(IConnectionMultiplexer multiplexer) + { + try + { + Task[] pending; + if (multiplexer is IInternalConnectionMultiplexer internalMultiplexer) + { + var snapshot = internalMultiplexer.GetServerSnapshot(); + pending = GetReusablePending(ref _reusablePending, snapshot.Length); + for (int i = 0; i < pending.Length; i++) + { + pending[i] = CheckHealthAsync(snapshot[i].GetRedisServer(null)); + } + } + else + { + var servers = multiplexer.GetServers(); + pending = GetReusablePending(ref _reusablePending, servers.Length); + for (int i = 0; i < pending.Length; i++) + { + pending[i] = CheckHealthAsync(servers[i]); + } + } + var result = await CollateAsync(pending, TotalTimeoutMillis()).ForAwait(); + + // on successful completion (regardless of outcome), we can reuse the pending array + PutReusablePending(ref _reusablePending, ref pending); + return result; + } + catch + { + // definitely unhappy + return HealthCheckResult.Unhealthy; + } + } + + internal int TotalTimeoutMillis() + { + int count = ProbeCount; + if (count <= 0) + { + Debug.Fail("We shouldn't get as far as calculating timeouts with a non-positive probe count."); + return 0; + } + + TimeSpan probeTimeout = ProbeTimeout, probeInterval = ProbeInterval; + + // the first probe doesn't have an interval before it, the rest do + var totalTicks = probeTimeout.Ticks + + ((probeTimeout.Ticks + probeInterval.Ticks) * (count - 1)); + var millis = (int)TimeSpan.FromTicks(totalTicks).TotalMilliseconds; + Debug.Assert(millis > 0, "Total timeout should be positive"); + return millis; + } + + // apply timeout and collation logic to a group of probes + internal static async Task CollateAsync(Task[] probes, int timeoutMilliseconds) + { + var pendingAll = Task.WhenAll(probes).ObserveErrors(); + int success = 0, failure = 0; + + if (await pendingAll.TimeoutAfter(timeoutMilliseconds).ForAwait()) + { + // all completed inside timeout; all results should now be available + for (int i = 0; i < probes.Length; i++) + { + var individualResult = await probes[i].ForAwait(); + switch (individualResult) + { + case HealthCheckResult.Healthy: success++; break; + case HealthCheckResult.Unhealthy: failure++; break; + } + } + } + else + { + // timeout + for (int i = 0; i < probes.Length; i++) + { + _ = probes[i].ObserveErrors(); + } + throw new TimeoutException(); + } + + if (failure > 0) return HealthCheckResult.Unhealthy; + if (success > 0) return HealthCheckResult.Healthy; + return HealthCheckResult.Inconclusive; + } + + private Task[]? _reusablePending; + + // The number of pending tasks is determined by the number of endpoints, which doesn't change frequently + // (if at all); consequently, we can often re-use this buffer between health-checks, as long as we're careful. + internal static Task[] GetReusablePending(ref Task[]? field, int count) + { + var result = Interlocked.Exchange(ref field, null); + if (result is null || result.Length != count) + { + result = count == 0 ? [] : new Task[count]; + } + return result; + } + + internal static void PutReusablePending(ref Task[]? field, ref Task[] value) + { + if (value is { Length: > 0 }) + { + Array.Clear(value, 0, value.Length); + Interlocked.Exchange(ref field, value); + value = []; + } + } + + /// + /// Evaluate the health of an endpoint. + /// + public Task CheckHealthAsync(IServer server) + => server.IsConnected ? CheckHealthCoreAsync(server) : HealthCheckProbe.UnhealthyTask; + + private async Task CheckHealthCoreAsync(IServer server) + { + try + { + int timeout = (int)ProbeTimeout.TotalMilliseconds, success = 0, failure = 0, remaining = ProbeCount; + while (remaining > 0) + { + HealthCheckResult probeResult; + try + { + var pendingProbe = Probe.CheckHealthAsync(this, server); + probeResult = await pendingProbe.TimeoutAfter(timeout).ForAwait() + ? await pendingProbe.ForAwait() // completed + : HealthCheckResult.Unhealthy; // timeout + } + catch + { + probeResult = HealthCheckResult.Unhealthy; + } + + // update success/failure counts + switch (probeResult) + { + case HealthCheckResult.Healthy: success++; break; + case HealthCheckResult.Unhealthy: failure++; break; + } + HealthCheckProbeContext ctx = new(probeResult, success, failure, --remaining, ProbeInterval); + + // evaluate the policy + var policyResult = ProbePolicy.Evaluate(in ctx); + if (policyResult != HealthCheckResult.Inconclusive) return policyResult; + + if (remaining > 0) + { + // delay if appropriate + var delay = ProbePolicy.GetEffectiveProbeInterval(in ctx); + if (delay > TimeSpan.Zero) + { + await Task.Delay(ctx.ProbeInterval).ConfigureAwait(false); + } + } + } + + // we got here without a result + return HealthCheckResult.Inconclusive; + } + catch (Exception ex) + { + // if the health check utterly fails: that isn't a good sign + Debug.WriteLine(ex.Message); + return HealthCheckResult.Unhealthy; + } + } +} diff --git a/src/StackExchange.Redis/HealthCheck.HealthCheckProbe.cs b/src/StackExchange.Redis/HealthCheck.HealthCheckProbe.cs new file mode 100644 index 0000000000..1d3e4f1a46 --- /dev/null +++ b/src/StackExchange.Redis/HealthCheck.HealthCheckProbe.cs @@ -0,0 +1,79 @@ +using System.Diagnostics; +using System.Net; +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +public sealed partial class HealthCheck +{ + /// + /// Describes an operation to perform as part of a health check. + /// + public abstract partial class HealthCheckProbe + { + /// + /// Check the health of the specified endpoint. + /// + public abstract Task CheckHealthAsync(HealthCheck healthCheck, IServer server); + + private static Task? _inconclusive, _healthy, _unhealthy; + + /// + /// Reports a memoized probe that was skipped without being evaluated. + /// + protected internal static Task InconclusiveTask => _inconclusive ??= Task.FromResult(HealthCheckResult.Inconclusive); + + /// + /// Reports a memoized probe that was healthy. + /// + protected internal static Task HealthyTask => _healthy ??= Task.FromResult(HealthCheckResult.Healthy); + + /// + /// Reports a memoized probe that was unhealthy. + /// + protected internal static Task UnhealthyTask => _unhealthy ??= Task.FromResult(HealthCheckResult.Unhealthy); + } + + /// + /// Describes a key-based (write) operation to perform as part of a health check. + /// + public abstract class KeyWriteHealthCheckProbe : HealthCheckProbe + { + /// + public override Task CheckHealthAsync(HealthCheck healthCheck, IServer server) + { + if (server.IsReplica) return InconclusiveTask; + + RedisKey key = server.InventKey("health-check/"); + if (key.IsNull) return InconclusiveTask; + Debug.Assert(server.Multiplexer.GetServer(key).EndPoint == server.EndPoint, "Key was not routed to the correct endpoint"); + return CheckHealthAsync(healthCheck, server.Multiplexer.GetDatabase(), key); + } + + /// + /// Check the health of the specified database using the provided key. + /// + public abstract Task CheckHealthAsync(HealthCheck healthCheck, IDatabaseAsync database, RedisKey key); + } + + /// + /// Indicates the result of a health check. + /// + public enum HealthCheckResult + { + /// + /// The health check was skipped or could not be determined. + /// + Inconclusive, + + /// + /// The health check was successful. + /// + Healthy, + + /// + /// The health check failed. + /// + Unhealthy, + } +} diff --git a/src/StackExchange.Redis/HealthCheck.HealthCheckProbeContext.cs b/src/StackExchange.Redis/HealthCheck.HealthCheckProbeContext.cs new file mode 100644 index 0000000000..3766e6e58a --- /dev/null +++ b/src/StackExchange.Redis/HealthCheck.HealthCheckProbeContext.cs @@ -0,0 +1,41 @@ +using System; +using System.Runtime.CompilerServices; + +namespace StackExchange.Redis; + +public sealed partial class HealthCheck +{ + /// + /// Represents the context of a health check probe. + /// + public readonly struct HealthCheckProbeContext(HealthCheckResult result, int success, int failure, int remaining, TimeSpan probeInterval) + { + /// + public override string ToString() => $"Result: {Result}, Success: {Success}, Failure: {Failure}, Remaining: {Remaining}, ProbeInterval: {ProbeInterval}"; + + /// + /// Gets the most recent result. + /// + public HealthCheckResult Result => result; + + /// + /// Gets the number of successful health checks. + /// + public int Success => success; + + /// + /// Gets the number of failed health checks. + /// + public int Failure => failure; + + /// + /// Gets the number of remaining health checks. + /// + public int Remaining => remaining; + + /// + /// Gets the interval to wait before the next probe attempt. + /// + public TimeSpan ProbeInterval => probeInterval; + } +} diff --git a/src/StackExchange.Redis/HealthCheck.HealthCheckProbePolicy.cs b/src/StackExchange.Redis/HealthCheck.HealthCheckProbePolicy.cs new file mode 100644 index 0000000000..b9edd2eaeb --- /dev/null +++ b/src/StackExchange.Redis/HealthCheck.HealthCheckProbePolicy.cs @@ -0,0 +1,102 @@ +using System; + +namespace StackExchange.Redis; + +public sealed partial class HealthCheck +{ + /// + /// Attempt to evaluate the outcome of a series of health check operations. + /// + public abstract class HealthCheckProbePolicy + { + /// + /// Attempt to evaluate the policy given the current context. + /// + /// The state of the probes so far. + /// The result of the policy evaluation. + public abstract HealthCheckResult Evaluate(in HealthCheckProbeContext context); + + /// + /// Get the interval to wait before the next probe attempt. + /// + internal TimeSpan GetEffectiveProbeInterval(in HealthCheckProbeContext context) + { + // if we make this public / overrideable, we will need to think about the max delay timeout + + // only delay between failures + return context.Result is HealthCheckResult.Unhealthy ? context.ProbeInterval : TimeSpan.Zero; + } + + /// + /// Require all probes to succeed. + /// + public static HealthCheckProbePolicy AllSuccess => AllSuccessHealthCheckProbePolicy.Instance; + + /// + /// Require at least one probe to succeed. + /// + public static HealthCheckProbePolicy AnySuccess => AnySuccessHealthCheckProbePolicy.Instance; + + /// + /// Require a majority of probes to succeed. + /// + public static HealthCheckProbePolicy MajoritySuccess => MajoritySuccessHealthCheckProbePolicy.Instance; + + private sealed class AllSuccessHealthCheckProbePolicy : HealthCheckProbePolicy + { + public static readonly AllSuccessHealthCheckProbePolicy Instance = new(); + private AllSuccessHealthCheckProbePolicy() { } + + public override HealthCheckResult Evaluate(in HealthCheckProbeContext context) + { + // Fail as soon as we have any failure + if (context.Failure > 0) return HealthCheckResult.Unhealthy; + + // Succeed only when all probes have succeeded (no remaining) + if (context.Remaining == 0) return HealthCheckResult.Healthy; + + // Can't determine yet + return HealthCheckResult.Inconclusive; + } + } + + private sealed class AnySuccessHealthCheckProbePolicy : HealthCheckProbePolicy + { + public static readonly AnySuccessHealthCheckProbePolicy Instance = new(); + private AnySuccessHealthCheckProbePolicy() { } + + public override HealthCheckResult Evaluate(in HealthCheckProbeContext context) + { + // Succeed as soon as we have any success + if (context.Success > 0) return HealthCheckResult.Healthy; + + // Fail only when all probes have failed (no remaining) + if (context.Remaining == 0) return HealthCheckResult.Unhealthy; + + // Can't determine yet + return HealthCheckResult.Inconclusive; + } + } + + private sealed class MajoritySuccessHealthCheckProbePolicy : HealthCheckProbePolicy + { + public static readonly MajoritySuccessHealthCheckProbePolicy Instance = new(); + private MajoritySuccessHealthCheckProbePolicy() { } + + public override HealthCheckResult Evaluate(in HealthCheckProbeContext context) + { + int total = context.Success + context.Failure + context.Remaining; + int majority = (total / 2) + 1; + + // Succeed as soon as we have enough successes for a majority + if (context.Success >= majority) return HealthCheckResult.Healthy; + + // Fail as soon as we have enough failures to make a majority impossible + if (context.Failure >= majority) return HealthCheckResult.Unhealthy; + + // Can't determine yet + return HealthCheckResult.Inconclusive; + } + } + } +} diff --git a/src/StackExchange.Redis/HealthCheck.PingProbe.cs b/src/StackExchange.Redis/HealthCheck.PingProbe.cs new file mode 100644 index 0000000000..37257e52d5 --- /dev/null +++ b/src/StackExchange.Redis/HealthCheck.PingProbe.cs @@ -0,0 +1,27 @@ +using System.Net; +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +public sealed partial class HealthCheck +{ + public partial class HealthCheckProbe + { + /// + /// Verify that the server is responsive by sending a PING command. + /// + public static HealthCheckProbe Ping => PingProbe.Instance; + } + + private sealed class PingProbe : HealthCheckProbe + { + public static PingProbe Instance { get; } = new(); + private PingProbe() { } + + public override async Task CheckHealthAsync(HealthCheck healthCheck, IServer server) + { + await server.PingAsync(); + return HealthCheckResult.Healthy; + } + } +} diff --git a/src/StackExchange.Redis/HealthCheck.StringSetProbe.cs b/src/StackExchange.Redis/HealthCheck.StringSetProbe.cs new file mode 100644 index 0000000000..256ddc0552 --- /dev/null +++ b/src/StackExchange.Redis/HealthCheck.StringSetProbe.cs @@ -0,0 +1,59 @@ +using System; +using System.Buffers; +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +public sealed partial class HealthCheck +{ + public partial class HealthCheckProbe + { + /// + /// Verify that a string can be successfully set and retrieved. + /// + public static HealthCheckProbe StringSet => StringSetProbe.Instance; + } + + internal sealed class StringSetProbe : KeyWriteHealthCheckProbe + { + public static StringSetProbe Instance { get; } = new(); + private StringSetProbe() { } + +#if !NET + private static Random SharedRandom { get; } = new(); +#endif + + public override async Task CheckHealthAsync(HealthCheck healthCheck, IDatabaseAsync database, RedisKey key) + { + // note we use the lock API here because that can selectively choose between appropriate strategies for + // different server versions, including DELEX + const int LEN = 16; + var pooled = ArrayPool.Shared.Rent(LEN); +#if NET + Random.Shared.NextBytes(pooled.AsSpan(0, LEN)); +#else + SharedRandom.NextBytes(pooled); +#endif + var payload = (RedisValue)pooled.AsMemory(0, LEN); + Lease? lease = null; + try + { + // write a value to the db + await database.LockTakeAsync( + key: key, + value: payload, + expiry: healthCheck.ProbeTimeout, + flags: CommandFlags.FireAndForget).ForAwait(); + + // release from the db if matches (otherwise, we have no clue what happened, so: leave alone) + var success = await database.LockReleaseAsync(key, payload).ForAwait(); + return success ? HealthCheckResult.Healthy : HealthCheckResult.Unhealthy; + } + finally + { + ArrayPool.Shared.Return(pooled); + lease?.Dispose(); + } + } + } +} diff --git a/src/StackExchange.Redis/HealthCheck.cs b/src/StackExchange.Redis/HealthCheck.cs new file mode 100644 index 0000000000..3f8dae3afc --- /dev/null +++ b/src/StackExchange.Redis/HealthCheck.cs @@ -0,0 +1,122 @@ +using System; +using System.Runtime.CompilerServices; +using System.Threading; + +namespace StackExchange.Redis; + +/// +/// Describes a health check to perform against instances. +/// +public sealed partial class HealthCheck : ICloneable +{ + private static HealthCheck? _default; + + /// + /// The default health check options. These options are immutable and cannot be modified; to customize, either + /// use to create a mutable copy, or create a new instance - and customize as needed. + /// + public static HealthCheck Default => _default ?? CreateDefault(); + + private static HealthCheck CreateDefault() + { + var options = new HealthCheck(); + options.Freeze(); + // memoize, preferring to re-use the existing instance if we're competing (but since frozen: that's fine) + return Interlocked.CompareExchange(ref _default, options, null) ?? options; + } + + internal void Freeze() => _frozen = true; + private bool _frozen; + + /// + /// Create a mutable copy of this health check. + /// + public HealthCheck Clone() => new() + { + // note: do not copy _frozen + Interval = Interval, + ProbeCount = ProbeCount, + ProbeTimeout = ProbeTimeout, + ProbeInterval = ProbeInterval, + Probe = Probe, + ProbePolicy = ProbePolicy, + }; + + object ICloneable.Clone() => Clone(); + + /// + /// Create a new health check instance. + /// + public HealthCheck() + { + Interval = TimeSpan.FromSeconds(10); + ProbeCount = 3; + ProbeTimeout = TimeSpan.FromSeconds(2); + ProbeInterval = TimeSpan.FromSeconds(1); + Probe = HealthCheckProbe.Ping; + ProbePolicy = HealthCheckProbePolicy.AnySuccess; + } + + /// + /// Gets or sets the interval at which health checks should be performed. + /// + public TimeSpan Interval + { + get; + set => SetField(ref field, value); + } + + /// + /// Gets or sets the number of probes to perform for this health check. + /// + public int ProbeCount + { + get; + set => SetField(ref field, value); + } + + /// + /// Gets or sets the time that should be allowed for an individual probe to complete. + /// + public TimeSpan ProbeTimeout + { + get; + set => SetField(ref field, value); + } + + /// + /// Gets or sets the interval between failed probes. + /// + public TimeSpan ProbeInterval + { + get; + set => SetField(ref field, value); + } + + /// + /// Gets or sets the probe to use for this health check. + /// + public HealthCheckProbe Probe + { + get; + set => SetField(ref field, value); + } + + /// + /// Gets or sets the policy to use for this health check. + /// + public HealthCheckProbePolicy ProbePolicy + { + get; + set => SetField(ref field, value); + } + + // ReSharper disable once RedundantAssignment + private void SetField(ref T field, T value, [CallerMemberName] string caller = "") + { + if (_frozen) Throw(caller); + field = value; + + static void Throw(string caller) => throw new InvalidOperationException($"{nameof(HealthCheck)}.{caller} cannot be modified once the object is in use."); + } +} diff --git a/src/StackExchange.Redis/Interfaces/IConnectionGroup.cs b/src/StackExchange.Redis/Interfaces/IConnectionGroup.cs new file mode 100644 index 0000000000..1afd732908 --- /dev/null +++ b/src/StackExchange.Redis/Interfaces/IConnectionGroup.cs @@ -0,0 +1,124 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using RESPite; + +// ReSharper disable once CheckNamespace +namespace StackExchange.Redis; + +/// +/// A group of connections to redis servers, that manages connections to multiple +/// servers, routing traffic based on the availability of the servers and their +/// relative . +/// +[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +public interface IConnectionGroup : IConnectionMultiplexer +{ + /// + /// A change occured to one of the connection groups. + /// + event EventHandler? ConnectionChanged; + + /// + /// Adds a new member to the group. + /// + Task AddAsync(ConnectionGroupMember member, TextWriter? log = null); + + /// + /// Attempt to explicitly switch to the specified member, or remove an explicit failover if is . + /// A member specified via this method will be preferred over other members (ignoring and ) + /// until the failover is removed, or the connection is no longer reachable. + /// + /// The member to switch to, or to remove an explicit failover. + /// if the failover was successful (i.e. the member can be used), otherwise. + bool TryFailoverTo(ConnectionGroupMember? member); + + /// + /// Removes a member from the group. + /// + bool Remove(ConnectionGroupMember member); + + /// + /// Get the members of the group. + /// + ReadOnlySpan GetMembers(); + + /// + /// Gets the currently active member. + /// + ConnectionGroupMember? ActiveMember { get; } +} + +/// +/// Represents a change to a connection group. +/// +[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +public class GroupConnectionChangedEventArgs(GroupConnectionChangedEventArgs.ChangeType type, ConnectionGroupMember group, ConnectionGroupMember? previousGroup = null) : EventArgs, ICompletable +{ + /// + /// The group relating to the change. For , this is the new group. + /// + public ConnectionGroupMember Group => group; + + /// + /// The previous group relating to the change, if applicable. + /// + public ConnectionGroupMember? PreviousGroup => previousGroup; + + /// + /// The type of change that occurred. + /// + public ChangeType Type => type; + + private EventHandler? _handler; + private object? _sender; + + /// + /// The type of change that occurred. + /// + public enum ChangeType + { + /// + /// Unused. + /// + Unknown = 0, + + /// + /// A new connection group was added. + /// + Added = 1, + + /// + /// A connection group was removed. + /// + Removed = 2, + + /// + /// A connection group became disconnected. + /// + Disconnected = 3, + + /// + /// A connection group became reconnected. + /// + Reconnected = 4, + + /// + /// The active connection group changed, changing how traffic is routed. + /// + ActiveChanged = 5, + } + + internal void CompleteAsWorker(EventHandler handler, object sender) + { + _handler = handler; + _sender = sender; + ConnectionMultiplexer.CompleteAsWorker(this); + } + + void ICompletable.AppendStormLog(StringBuilder sb) { } + + bool ICompletable.TryComplete(bool isAsync) => ConnectionMultiplexer.TryCompleteHandler(_handler, _sender!, this, isAsync); +} diff --git a/src/StackExchange.Redis/Interfaces/IServer.cs b/src/StackExchange.Redis/Interfaces/IServer.cs index 1959f15d8f..975b4c8a73 100644 --- a/src/StackExchange.Redis/Interfaces/IServer.cs +++ b/src/StackExchange.Redis/Interfaces/IServer.cs @@ -810,6 +810,11 @@ public partial interface IServer : IRedis /// Task[][]> SentinelSentinelsAsync(string serviceName, CommandFlags flags = CommandFlags.None); + + /// + /// Invent a random key that will resolve to this server. + /// + RedisKey InventKey(RedisKey prefix = default); } internal static class IServerExtensions diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Async.cs b/src/StackExchange.Redis/MultiGroupDatabase.Async.cs new file mode 100644 index 0000000000..03706f3551 --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.Async.cs @@ -0,0 +1,66 @@ +using System; +using System.Net; +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // Async methods - Core operations + public Task DebugObjectAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().DebugObjectAsync(key, flags); + + public Task IdentifyEndpointAsync(RedisKey key = default, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().IdentifyEndpointAsync(key, flags); + + public Task KeyMigrateAsync(RedisKey key, EndPoint toServer, int toDatabase = 0, int timeoutMilliseconds = 0, MigrateOptions migrateOptions = MigrateOptions.None, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyMigrateAsync(key, toServer, toDatabase, timeoutMilliseconds, migrateOptions, flags); + + public Task PingAsync(CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().PingAsync(flags); + + public Task PublishAsync(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().PublishAsync(channel, message, flags); + + public Task ExecuteAsync(string command, params object[] args) + => GetActiveDatabase().ExecuteAsync(command, args); + + public Task ExecuteAsync(string command, System.Collections.Generic.ICollection? args, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ExecuteAsync(command, args, flags); + + public Task ScriptEvaluateAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ScriptEvaluateAsync(script, keys, values, flags); + + public Task ScriptEvaluateAsync(byte[] hash, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ScriptEvaluateAsync(hash, keys, values, flags); + + public Task ScriptEvaluateAsync(LuaScript script, object? parameters = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ScriptEvaluateAsync(script, parameters, flags); + + public Task ScriptEvaluateAsync(LoadedLuaScript script, object? parameters = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ScriptEvaluateAsync(script, parameters, flags); + + public Task ScriptEvaluateReadOnlyAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ScriptEvaluateReadOnlyAsync(script, keys, values, flags); + + public Task ScriptEvaluateReadOnlyAsync(byte[] hash, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ScriptEvaluateReadOnlyAsync(hash, keys, values, flags); + + public Task LockExtendAsync(RedisKey key, RedisValue value, TimeSpan expiry, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().LockExtendAsync(key, value, expiry, flags); + + public Task LockQueryAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().LockQueryAsync(key, flags); + + public Task LockReleaseAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().LockReleaseAsync(key, value, flags); + + public Task LockTakeAsync(RedisKey key, RedisValue value, TimeSpan expiry, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().LockTakeAsync(key, value, expiry, flags); + + public Task SortAsync(RedisKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, RedisValue by = default, RedisValue[]? get = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortAsync(key, skip, take, order, sortType, by, get, flags); + + public Task SortAndStoreAsync(RedisKey destination, RedisKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, RedisValue by = default, RedisValue[]? get = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortAndStoreAsync(destination, key, skip, take, order, sortType, by, get, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Geo.Async.cs b/src/StackExchange.Redis/MultiGroupDatabase.Geo.Async.cs new file mode 100644 index 0000000000..422ff59fc9 --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.Geo.Async.cs @@ -0,0 +1,52 @@ +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // Geo Async + public Task GeoAddAsync(RedisKey key, double longitude, double latitude, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoAddAsync(key, longitude, latitude, member, flags); + + public Task GeoAddAsync(RedisKey key, GeoEntry value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoAddAsync(key, value, flags); + + public Task GeoAddAsync(RedisKey key, GeoEntry[] values, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoAddAsync(key, values, flags); + + public Task GeoRemoveAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoRemoveAsync(key, member, flags); + + public Task GeoDistanceAsync(RedisKey key, RedisValue member1, RedisValue member2, GeoUnit unit = GeoUnit.Meters, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoDistanceAsync(key, member1, member2, unit, flags); + + public Task GeoHashAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoHashAsync(key, members, flags); + + public Task GeoHashAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoHashAsync(key, member, flags); + + public Task GeoPositionAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoPositionAsync(key, members, flags); + + public Task GeoPositionAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoPositionAsync(key, member, flags); + + public Task GeoRadiusAsync(RedisKey key, RedisValue member, double radius, GeoUnit unit = GeoUnit.Meters, int count = -1, Order? order = null, GeoRadiusOptions options = GeoRadiusOptions.Default, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoRadiusAsync(key, member, radius, unit, count, order, options, flags); + + public Task GeoRadiusAsync(RedisKey key, double longitude, double latitude, double radius, GeoUnit unit = GeoUnit.Meters, int count = -1, Order? order = null, GeoRadiusOptions options = GeoRadiusOptions.Default, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoRadiusAsync(key, longitude, latitude, radius, unit, count, order, options, flags); + + public Task GeoSearchAsync(RedisKey key, RedisValue member, GeoSearchShape shape, int count = -1, bool demandClosest = true, Order? order = null, GeoRadiusOptions options = GeoRadiusOptions.Default, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoSearchAsync(key, member, shape, count, demandClosest, order, options, flags); + + public Task GeoSearchAsync(RedisKey key, double longitude, double latitude, GeoSearchShape shape, int count = -1, bool demandClosest = true, Order? order = null, GeoRadiusOptions options = GeoRadiusOptions.Default, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoSearchAsync(key, longitude, latitude, shape, count, demandClosest, order, options, flags); + + public Task GeoSearchAndStoreAsync(RedisKey sourceKey, RedisKey destinationKey, RedisValue member, GeoSearchShape shape, int count = -1, bool demandClosest = true, Order? order = null, bool storeDistances = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoSearchAndStoreAsync(sourceKey, destinationKey, member, shape, count, demandClosest, order, storeDistances, flags); + + public Task GeoSearchAndStoreAsync(RedisKey sourceKey, RedisKey destinationKey, double longitude, double latitude, GeoSearchShape shape, int count = -1, bool demandClosest = true, Order? order = null, bool storeDistances = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoSearchAndStoreAsync(sourceKey, destinationKey, longitude, latitude, shape, count, demandClosest, order, storeDistances, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Geo.cs b/src/StackExchange.Redis/MultiGroupDatabase.Geo.cs new file mode 100644 index 0000000000..c74982c835 --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.Geo.cs @@ -0,0 +1,50 @@ +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // Geo operations + public bool GeoAdd(RedisKey key, double longitude, double latitude, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoAdd(key, longitude, latitude, member, flags); + + public bool GeoAdd(RedisKey key, GeoEntry value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoAdd(key, value, flags); + + public long GeoAdd(RedisKey key, GeoEntry[] values, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoAdd(key, values, flags); + + public bool GeoRemove(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoRemove(key, member, flags); + + public double? GeoDistance(RedisKey key, RedisValue member1, RedisValue member2, GeoUnit unit = GeoUnit.Meters, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoDistance(key, member1, member2, unit, flags); + + public string?[] GeoHash(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoHash(key, members, flags); + + public string? GeoHash(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoHash(key, member, flags); + + public GeoPosition?[] GeoPosition(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoPosition(key, members, flags); + + public GeoPosition? GeoPosition(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoPosition(key, member, flags); + + public GeoRadiusResult[] GeoRadius(RedisKey key, RedisValue member, double radius, GeoUnit unit = GeoUnit.Meters, int count = -1, Order? order = null, GeoRadiusOptions options = GeoRadiusOptions.Default, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoRadius(key, member, radius, unit, count, order, options, flags); + + public GeoRadiusResult[] GeoRadius(RedisKey key, double longitude, double latitude, double radius, GeoUnit unit = GeoUnit.Meters, int count = -1, Order? order = null, GeoRadiusOptions options = GeoRadiusOptions.Default, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoRadius(key, longitude, latitude, radius, unit, count, order, options, flags); + + public GeoRadiusResult[] GeoSearch(RedisKey key, RedisValue member, GeoSearchShape shape, int count = -1, bool demandClosest = true, Order? order = null, GeoRadiusOptions options = GeoRadiusOptions.Default, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoSearch(key, member, shape, count, demandClosest, order, options, flags); + + public GeoRadiusResult[] GeoSearch(RedisKey key, double longitude, double latitude, GeoSearchShape shape, int count = -1, bool demandClosest = true, Order? order = null, GeoRadiusOptions options = GeoRadiusOptions.Default, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoSearch(key, longitude, latitude, shape, count, demandClosest, order, options, flags); + + public long GeoSearchAndStore(RedisKey sourceKey, RedisKey destinationKey, RedisValue member, GeoSearchShape shape, int count = -1, bool demandClosest = true, Order? order = null, bool storeDistances = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoSearchAndStore(sourceKey, destinationKey, member, shape, count, demandClosest, order, storeDistances, flags); + + public long GeoSearchAndStore(RedisKey sourceKey, RedisKey destinationKey, double longitude, double latitude, GeoSearchShape shape, int count = -1, bool demandClosest = true, Order? order = null, bool storeDistances = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().GeoSearchAndStore(sourceKey, destinationKey, longitude, latitude, shape, count, demandClosest, order, storeDistances, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Hashes.Async.cs b/src/StackExchange.Redis/MultiGroupDatabase.Hashes.Async.cs new file mode 100644 index 0000000000..15e8111e83 --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.Hashes.Async.cs @@ -0,0 +1,128 @@ +using System; +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // Hash Async + public Task HashDecrementAsync(RedisKey key, RedisValue hashField, long value = 1, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashDecrementAsync(key, hashField, value, flags); + + public Task HashDecrementAsync(RedisKey key, RedisValue hashField, double value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashDecrementAsync(key, hashField, value, flags); + + public Task HashDeleteAsync(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashDeleteAsync(key, hashField, flags); + + public Task HashDeleteAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashDeleteAsync(key, hashFields, flags); + + public Task HashExistsAsync(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashExistsAsync(key, hashField, flags); + + public Task HashFieldExpireAsync(RedisKey key, RedisValue[] hashFields, TimeSpan expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldExpireAsync(key, hashFields, expiry, when, flags); + + public Task HashFieldExpireAsync(RedisKey key, RedisValue[] hashFields, DateTime expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldExpireAsync(key, hashFields, expiry, when, flags); + + public Task HashFieldGetExpireDateTimeAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldGetExpireDateTimeAsync(key, hashFields, flags); + + public Task HashFieldPersistAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldPersistAsync(key, hashFields, flags); + + public Task HashFieldGetTimeToLiveAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldGetTimeToLiveAsync(key, hashFields, flags); + + public Task HashGetAsync(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashGetAsync(key, hashField, flags); + + public Task?> HashGetLeaseAsync(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashGetLeaseAsync(key, hashField, flags); + + public Task HashGetAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashGetAsync(key, hashFields, flags); + + public Task HashFieldGetAndDeleteAsync(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldGetAndDeleteAsync(key, hashField, flags); + + public Task?> HashFieldGetLeaseAndDeleteAsync(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldGetLeaseAndDeleteAsync(key, hashField, flags); + + public Task HashFieldGetAndDeleteAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldGetAndDeleteAsync(key, hashFields, flags); + + public Task HashFieldGetAndSetExpiryAsync(RedisKey key, RedisValue hashField, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldGetAndSetExpiryAsync(key, hashField, expiry, persist, flags); + + public Task HashFieldGetAndSetExpiryAsync(RedisKey key, RedisValue hashField, DateTime expiry, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldGetAndSetExpiryAsync(key, hashField, expiry, flags); + + public Task?> HashFieldGetLeaseAndSetExpiryAsync(RedisKey key, RedisValue hashField, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldGetLeaseAndSetExpiryAsync(key, hashField, expiry, persist, flags); + + public Task?> HashFieldGetLeaseAndSetExpiryAsync(RedisKey key, RedisValue hashField, DateTime expiry, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldGetLeaseAndSetExpiryAsync(key, hashField, expiry, flags); + + public Task HashFieldGetAndSetExpiryAsync(RedisKey key, RedisValue[] hashFields, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldGetAndSetExpiryAsync(key, hashFields, expiry, persist, flags); + + public Task HashFieldGetAndSetExpiryAsync(RedisKey key, RedisValue[] hashFields, DateTime expiry, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldGetAndSetExpiryAsync(key, hashFields, expiry, flags); + + public Task HashFieldSetAndSetExpiryAsync(RedisKey key, RedisValue field, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldSetAndSetExpiryAsync(key, field, value, expiry, keepTtl, when, flags); + + public Task HashFieldSetAndSetExpiryAsync(RedisKey key, RedisValue field, RedisValue value, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldSetAndSetExpiryAsync(key, field, value, expiry, when, flags); + + public Task HashFieldSetAndSetExpiryAsync(RedisKey key, HashEntry[] hashFields, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldSetAndSetExpiryAsync(key, hashFields, expiry, keepTtl, when, flags); + + public Task HashFieldSetAndSetExpiryAsync(RedisKey key, HashEntry[] hashFields, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldSetAndSetExpiryAsync(key, hashFields, expiry, when, flags); + + public Task HashGetAllAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashGetAllAsync(key, flags); + + public Task HashIncrementAsync(RedisKey key, RedisValue hashField, long value = 1, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashIncrementAsync(key, hashField, value, flags); + + public Task HashIncrementAsync(RedisKey key, RedisValue hashField, double value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashIncrementAsync(key, hashField, value, flags); + + public Task HashKeysAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashKeysAsync(key, flags); + + public Task HashLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashLengthAsync(key, flags); + + public Task HashRandomFieldAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashRandomFieldAsync(key, flags); + + public Task HashRandomFieldsAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashRandomFieldsAsync(key, count, flags); + + public Task HashRandomFieldsWithValuesAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashRandomFieldsWithValuesAsync(key, count, flags); + + public System.Collections.Generic.IAsyncEnumerable HashScanAsync(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashScanAsync(key, pattern, pageSize, cursor, pageOffset, flags); + + public System.Collections.Generic.IAsyncEnumerable HashScanNoValuesAsync(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashScanNoValuesAsync(key, pattern, pageSize, cursor, pageOffset, flags); + + public Task HashSetAsync(RedisKey key, RedisValue hashField, RedisValue value, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashSetAsync(key, hashField, value, when, flags); + + public Task HashSetAsync(RedisKey key, HashEntry[] hashFields, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashSetAsync(key, hashFields, flags); + + public Task HashStringLengthAsync(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashStringLengthAsync(key, hashField, flags); + + public Task HashValuesAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashValuesAsync(key, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Hashes.cs b/src/StackExchange.Redis/MultiGroupDatabase.Hashes.cs new file mode 100644 index 0000000000..312a4dcb06 --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.Hashes.cs @@ -0,0 +1,130 @@ +using System; + +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // Hash operations + public long HashDecrement(RedisKey key, RedisValue hashField, long value = 1, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashDecrement(key, hashField, value, flags); + + public double HashDecrement(RedisKey key, RedisValue hashField, double value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashDecrement(key, hashField, value, flags); + + public bool HashDelete(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashDelete(key, hashField, flags); + + public long HashDelete(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashDelete(key, hashFields, flags); + + public bool HashExists(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashExists(key, hashField, flags); + + public ExpireResult[] HashFieldExpire(RedisKey key, RedisValue[] hashFields, TimeSpan expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldExpire(key, hashFields, expiry, when, flags); + + public ExpireResult[] HashFieldExpire(RedisKey key, RedisValue[] hashFields, DateTime expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldExpire(key, hashFields, expiry, when, flags); + + public long[] HashFieldGetExpireDateTime(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldGetExpireDateTime(key, hashFields, flags); + + public PersistResult[] HashFieldPersist(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldPersist(key, hashFields, flags); + + public long[] HashFieldGetTimeToLive(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldGetTimeToLive(key, hashFields, flags); + + public RedisValue HashGet(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashGet(key, hashField, flags); + + public Lease? HashGetLease(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashGetLease(key, hashField, flags); + + public RedisValue[] HashGet(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashGet(key, hashFields, flags); + + public RedisValue HashFieldGetAndDelete(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldGetAndDelete(key, hashField, flags); + + public Lease? HashFieldGetLeaseAndDelete(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldGetLeaseAndDelete(key, hashField, flags); + + public RedisValue[] HashFieldGetAndDelete(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldGetAndDelete(key, hashFields, flags); + + public RedisValue HashFieldGetAndSetExpiry(RedisKey key, RedisValue hashField, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldGetAndSetExpiry(key, hashField, expiry, persist, flags); + + public RedisValue HashFieldGetAndSetExpiry(RedisKey key, RedisValue hashField, DateTime expiry, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldGetAndSetExpiry(key, hashField, expiry, flags); + + public Lease? HashFieldGetLeaseAndSetExpiry(RedisKey key, RedisValue hashField, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldGetLeaseAndSetExpiry(key, hashField, expiry, persist, flags); + + public Lease? HashFieldGetLeaseAndSetExpiry(RedisKey key, RedisValue hashField, DateTime expiry, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldGetLeaseAndSetExpiry(key, hashField, expiry, flags); + + public RedisValue[] HashFieldGetAndSetExpiry(RedisKey key, RedisValue[] hashFields, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldGetAndSetExpiry(key, hashFields, expiry, persist, flags); + + public RedisValue[] HashFieldGetAndSetExpiry(RedisKey key, RedisValue[] hashFields, DateTime expiry, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldGetAndSetExpiry(key, hashFields, expiry, flags); + + public RedisValue HashFieldSetAndSetExpiry(RedisKey key, RedisValue field, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldSetAndSetExpiry(key, field, value, expiry, keepTtl, when, flags); + + public RedisValue HashFieldSetAndSetExpiry(RedisKey key, RedisValue field, RedisValue value, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldSetAndSetExpiry(key, field, value, expiry, when, flags); + + public RedisValue HashFieldSetAndSetExpiry(RedisKey key, HashEntry[] hashFields, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldSetAndSetExpiry(key, hashFields, expiry, keepTtl, when, flags); + + public RedisValue HashFieldSetAndSetExpiry(RedisKey key, HashEntry[] hashFields, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashFieldSetAndSetExpiry(key, hashFields, expiry, when, flags); + + public HashEntry[] HashGetAll(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashGetAll(key, flags); + + public long HashIncrement(RedisKey key, RedisValue hashField, long value = 1, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashIncrement(key, hashField, value, flags); + + public double HashIncrement(RedisKey key, RedisValue hashField, double value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashIncrement(key, hashField, value, flags); + + public RedisValue[] HashKeys(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashKeys(key, flags); + + public long HashLength(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashLength(key, flags); + + public RedisValue HashRandomField(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashRandomField(key, flags); + + public RedisValue[] HashRandomFields(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashRandomFields(key, count, flags); + + public HashEntry[] HashRandomFieldsWithValues(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashRandomFieldsWithValues(key, count, flags); + + public System.Collections.Generic.IEnumerable HashScan(RedisKey key, RedisValue pattern, int pageSize, CommandFlags flags) + => GetActiveDatabase().HashScan(key, pattern, pageSize, flags); + + public System.Collections.Generic.IEnumerable HashScan(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashScan(key, pattern, pageSize, cursor, pageOffset, flags); + + public System.Collections.Generic.IEnumerable HashScanNoValues(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashScanNoValues(key, pattern, pageSize, cursor, pageOffset, flags); + + public bool HashSet(RedisKey key, RedisValue hashField, RedisValue value, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashSet(key, hashField, value, when, flags); + + public void HashSet(RedisKey key, HashEntry[] hashFields, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashSet(key, hashFields, flags); + + public long HashStringLength(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashStringLength(key, hashField, flags); + + public RedisValue[] HashValues(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashValues(key, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.HyperLogLog.Async.cs b/src/StackExchange.Redis/MultiGroupDatabase.HyperLogLog.Async.cs new file mode 100644 index 0000000000..d57166885d --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.HyperLogLog.Async.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // HyperLogLog Async + public Task HyperLogLogAddAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HyperLogLogAddAsync(key, value, flags); + + public Task HyperLogLogAddAsync(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HyperLogLogAddAsync(key, values, flags); + + public Task HyperLogLogLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HyperLogLogLengthAsync(key, flags); + + public Task HyperLogLogLengthAsync(RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HyperLogLogLengthAsync(keys, flags); + + public Task HyperLogLogMergeAsync(RedisKey destination, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HyperLogLogMergeAsync(destination, first, second, flags); + + public Task HyperLogLogMergeAsync(RedisKey destination, RedisKey[] sourceKeys, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HyperLogLogMergeAsync(destination, sourceKeys, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.HyperLogLog.cs b/src/StackExchange.Redis/MultiGroupDatabase.HyperLogLog.cs new file mode 100644 index 0000000000..3312b4fc03 --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.HyperLogLog.cs @@ -0,0 +1,23 @@ +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // HyperLogLog operations + public bool HyperLogLogAdd(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HyperLogLogAdd(key, value, flags); + + public bool HyperLogLogAdd(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HyperLogLogAdd(key, values, flags); + + public long HyperLogLogLength(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HyperLogLogLength(key, flags); + + public long HyperLogLogLength(RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HyperLogLogLength(keys, flags); + + public void HyperLogLogMerge(RedisKey destination, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HyperLogLogMerge(destination, first, second, flags); + + public void HyperLogLogMerge(RedisKey destination, RedisKey[] sourceKeys, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HyperLogLogMerge(destination, sourceKeys, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Keys.Async.cs b/src/StackExchange.Redis/MultiGroupDatabase.Keys.Async.cs new file mode 100644 index 0000000000..76c7798bed --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.Keys.Async.cs @@ -0,0 +1,80 @@ +using System; +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // Key Async + public Task KeyCopyAsync(RedisKey sourceKey, RedisKey destinationKey, int destinationDatabase = -1, bool replace = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyCopyAsync(sourceKey, destinationKey, destinationDatabase, replace, flags); + + public Task KeyDeleteAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyDeleteAsync(key, flags); + + public Task KeyDeleteAsync(RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyDeleteAsync(keys, flags); + + public Task KeyDumpAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyDumpAsync(key, flags); + + public Task KeyEncodingAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyEncodingAsync(key, flags); + + public Task KeyExistsAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyExistsAsync(key, flags); + + public Task KeyExistsAsync(RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyExistsAsync(keys, flags); + + public Task KeyExpireAsync(RedisKey key, TimeSpan? expiry, CommandFlags flags) + => GetActiveDatabase().KeyExpireAsync(key, expiry, flags); + + public Task KeyExpireAsync(RedisKey key, TimeSpan? expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyExpireAsync(key, expiry, when, flags); + + public Task KeyExpireAsync(RedisKey key, DateTime? expiry, CommandFlags flags) + => GetActiveDatabase().KeyExpireAsync(key, expiry, flags); + + public Task KeyExpireAsync(RedisKey key, DateTime? expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyExpireAsync(key, expiry, when, flags); + + public Task KeyExpireTimeAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyExpireTimeAsync(key, flags); + + public Task KeyFrequencyAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyFrequencyAsync(key, flags); + + public Task KeyIdleTimeAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyIdleTimeAsync(key, flags); + + public Task KeyMoveAsync(RedisKey key, int database, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyMoveAsync(key, database, flags); + + public Task KeyPersistAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyPersistAsync(key, flags); + + public Task KeyRandomAsync(CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyRandomAsync(flags); + + public Task KeyRefCountAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyRefCountAsync(key, flags); + + public Task KeyRenameAsync(RedisKey key, RedisKey newKey, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyRenameAsync(key, newKey, when, flags); + + public Task KeyRestoreAsync(RedisKey key, byte[] value, TimeSpan? expiry = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyRestoreAsync(key, value, expiry, flags); + + public Task KeyTimeToLiveAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyTimeToLiveAsync(key, flags); + + public Task KeyTouchAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyTouchAsync(key, flags); + + public Task KeyTouchAsync(RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyTouchAsync(keys, flags); + + public Task KeyTypeAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyTypeAsync(key, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Keys.cs b/src/StackExchange.Redis/MultiGroupDatabase.Keys.cs new file mode 100644 index 0000000000..2ee6bdce05 --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.Keys.cs @@ -0,0 +1,79 @@ +using System; + +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // Key operations + public bool KeyCopy(RedisKey sourceKey, RedisKey destinationKey, int destinationDatabase = -1, bool replace = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyCopy(sourceKey, destinationKey, destinationDatabase, replace, flags); + + public bool KeyDelete(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyDelete(key, flags); + + public long KeyDelete(RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyDelete(keys, flags); + + public byte[]? KeyDump(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyDump(key, flags); + + public string? KeyEncoding(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyEncoding(key, flags); + + public bool KeyExists(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyExists(key, flags); + + public long KeyExists(RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyExists(keys, flags); + + public bool KeyExpire(RedisKey key, TimeSpan? expiry, CommandFlags flags) + => GetActiveDatabase().KeyExpire(key, expiry, flags); + + public bool KeyExpire(RedisKey key, TimeSpan? expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyExpire(key, expiry, when, flags); + + public bool KeyExpire(RedisKey key, DateTime? expiry, CommandFlags flags) + => GetActiveDatabase().KeyExpire(key, expiry, flags); + + public bool KeyExpire(RedisKey key, DateTime? expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyExpire(key, expiry, when, flags); + + public DateTime? KeyExpireTime(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyExpireTime(key, flags); + + public long? KeyFrequency(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyFrequency(key, flags); + + public TimeSpan? KeyIdleTime(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyIdleTime(key, flags); + + public bool KeyMove(RedisKey key, int database, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyMove(key, database, flags); + + public bool KeyPersist(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyPersist(key, flags); + + public RedisKey KeyRandom(CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyRandom(flags); + + public long? KeyRefCount(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyRefCount(key, flags); + + public bool KeyRename(RedisKey key, RedisKey newKey, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyRename(key, newKey, when, flags); + + public void KeyRestore(RedisKey key, byte[] value, TimeSpan? expiry = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyRestore(key, value, expiry, flags); + + public TimeSpan? KeyTimeToLive(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyTimeToLive(key, flags); + + public bool KeyTouch(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyTouch(key, flags); + + public long KeyTouch(RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyTouch(keys, flags); + + public RedisType KeyType(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyType(key, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Lists.Async.cs b/src/StackExchange.Redis/MultiGroupDatabase.Lists.Async.cs new file mode 100644 index 0000000000..d284dfee9b --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.Lists.Async.cs @@ -0,0 +1,79 @@ +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // List Async operations + public Task ListGetByIndexAsync(RedisKey key, long index, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListGetByIndexAsync(key, index, flags); + + public Task ListInsertAfterAsync(RedisKey key, RedisValue pivot, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListInsertAfterAsync(key, pivot, value, flags); + + public Task ListInsertBeforeAsync(RedisKey key, RedisValue pivot, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListInsertBeforeAsync(key, pivot, value, flags); + + public Task ListLeftPopAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListLeftPopAsync(key, flags); + + public Task ListLeftPopAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListLeftPopAsync(key, count, flags); + + public Task ListLeftPopAsync(RedisKey[] keys, long count, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListLeftPopAsync(keys, count, flags); + + public Task ListLeftPushAsync(RedisKey key, RedisValue value, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListLeftPushAsync(key, value, when, flags); + + public Task ListLeftPushAsync(RedisKey key, RedisValue[] values, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListLeftPushAsync(key, values, when, flags); + + public Task ListLeftPushAsync(RedisKey key, RedisValue[] values, CommandFlags flags) + => GetActiveDatabase().ListLeftPushAsync(key, values, flags); + + public Task ListLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListLengthAsync(key, flags); + + public Task ListMoveAsync(RedisKey sourceKey, RedisKey destinationKey, ListSide sourceSide, ListSide destinationSide, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListMoveAsync(sourceKey, destinationKey, sourceSide, destinationSide, flags); + + public Task ListPositionAsync(RedisKey key, RedisValue element, long rank = 1, long maxLength = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListPositionAsync(key, element, rank, maxLength, flags); + + public Task ListPositionsAsync(RedisKey key, RedisValue element, long count, long rank = 1, long maxLength = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListPositionsAsync(key, element, count, rank, maxLength, flags); + + public Task ListRangeAsync(RedisKey key, long start = 0, long stop = -1, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListRangeAsync(key, start, stop, flags); + + public Task ListRemoveAsync(RedisKey key, RedisValue value, long count = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListRemoveAsync(key, value, count, flags); + + public Task ListRightPopAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListRightPopAsync(key, flags); + + public Task ListRightPopAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListRightPopAsync(key, count, flags); + + public Task ListRightPopAsync(RedisKey[] keys, long count, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListRightPopAsync(keys, count, flags); + + public Task ListRightPopLeftPushAsync(RedisKey source, RedisKey destination, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListRightPopLeftPushAsync(source, destination, flags); + + public Task ListRightPushAsync(RedisKey key, RedisValue value, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListRightPushAsync(key, value, when, flags); + + public Task ListRightPushAsync(RedisKey key, RedisValue[] values, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListRightPushAsync(key, values, when, flags); + + public Task ListRightPushAsync(RedisKey key, RedisValue[] values, CommandFlags flags) + => GetActiveDatabase().ListRightPushAsync(key, values, flags); + + public Task ListSetByIndexAsync(RedisKey key, long index, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListSetByIndexAsync(key, index, value, flags); + + public Task ListTrimAsync(RedisKey key, long start, long stop, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListTrimAsync(key, start, stop, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Lists.cs b/src/StackExchange.Redis/MultiGroupDatabase.Lists.cs new file mode 100644 index 0000000000..58ffc7a1b1 --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.Lists.cs @@ -0,0 +1,77 @@ +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // List operations + public RedisValue ListGetByIndex(RedisKey key, long index, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListGetByIndex(key, index, flags); + + public long ListInsertAfter(RedisKey key, RedisValue pivot, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListInsertAfter(key, pivot, value, flags); + + public long ListInsertBefore(RedisKey key, RedisValue pivot, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListInsertBefore(key, pivot, value, flags); + + public RedisValue ListLeftPop(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListLeftPop(key, flags); + + public RedisValue[] ListLeftPop(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListLeftPop(key, count, flags); + + public ListPopResult ListLeftPop(RedisKey[] keys, long count, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListLeftPop(keys, count, flags); + + public long ListLeftPush(RedisKey key, RedisValue value, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListLeftPush(key, value, when, flags); + + public long ListLeftPush(RedisKey key, RedisValue[] values, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListLeftPush(key, values, when, flags); + + public long ListLeftPush(RedisKey key, RedisValue[] values, CommandFlags flags) + => GetActiveDatabase().ListLeftPush(key, values, flags); + + public long ListLength(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListLength(key, flags); + + public RedisValue ListMove(RedisKey sourceKey, RedisKey destinationKey, ListSide sourceSide, ListSide destinationSide, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListMove(sourceKey, destinationKey, sourceSide, destinationSide, flags); + + public long ListPosition(RedisKey key, RedisValue element, long rank = 1, long maxLength = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListPosition(key, element, rank, maxLength, flags); + + public long[] ListPositions(RedisKey key, RedisValue element, long count, long rank = 1, long maxLength = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListPositions(key, element, count, rank, maxLength, flags); + + public RedisValue[] ListRange(RedisKey key, long start = 0, long stop = -1, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListRange(key, start, stop, flags); + + public long ListRemove(RedisKey key, RedisValue value, long count = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListRemove(key, value, count, flags); + + public RedisValue ListRightPop(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListRightPop(key, flags); + + public RedisValue[] ListRightPop(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListRightPop(key, count, flags); + + public ListPopResult ListRightPop(RedisKey[] keys, long count, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListRightPop(keys, count, flags); + + public RedisValue ListRightPopLeftPush(RedisKey source, RedisKey destination, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListRightPopLeftPush(source, destination, flags); + + public long ListRightPush(RedisKey key, RedisValue value, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListRightPush(key, value, when, flags); + + public long ListRightPush(RedisKey key, RedisValue[] values, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListRightPush(key, values, when, flags); + + public long ListRightPush(RedisKey key, RedisValue[] values, CommandFlags flags) + => GetActiveDatabase().ListRightPush(key, values, flags); + + public void ListSetByIndex(RedisKey key, long index, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListSetByIndex(key, index, value, flags); + + public void ListTrim(RedisKey key, long start, long stop, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ListTrim(key, start, stop, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Sets.Async.cs b/src/StackExchange.Redis/MultiGroupDatabase.Sets.Async.cs new file mode 100644 index 0000000000..7d8eddf549 --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.Sets.Async.cs @@ -0,0 +1,64 @@ +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // Set Async operations + public Task SetAddAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetAddAsync(key, value, flags); + + public Task SetAddAsync(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetAddAsync(key, values, flags); + + public Task SetCombineAsync(SetOperation operation, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetCombineAsync(operation, first, second, flags); + + public Task SetCombineAsync(SetOperation operation, RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetCombineAsync(operation, keys, flags); + + public Task SetCombineAndStoreAsync(SetOperation operation, RedisKey destination, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetCombineAndStoreAsync(operation, destination, first, second, flags); + + public Task SetCombineAndStoreAsync(SetOperation operation, RedisKey destination, RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetCombineAndStoreAsync(operation, destination, keys, flags); + + public Task SetContainsAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetContainsAsync(key, value, flags); + + public Task SetContainsAsync(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetContainsAsync(key, values, flags); + + public Task SetIntersectionLengthAsync(RedisKey[] keys, long limit = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetIntersectionLengthAsync(keys, limit, flags); + + public Task SetLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetLengthAsync(key, flags); + + public Task SetMembersAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetMembersAsync(key, flags); + + public Task SetMoveAsync(RedisKey source, RedisKey destination, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetMoveAsync(source, destination, value, flags); + + public Task SetPopAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetPopAsync(key, flags); + + public Task SetPopAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetPopAsync(key, count, flags); + + public Task SetRandomMemberAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetRandomMemberAsync(key, flags); + + public Task SetRandomMembersAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetRandomMembersAsync(key, count, flags); + + public Task SetRemoveAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetRemoveAsync(key, value, flags); + + public Task SetRemoveAsync(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetRemoveAsync(key, values, flags); + + public System.Collections.Generic.IAsyncEnumerable SetScanAsync(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetScanAsync(key, pattern, pageSize, cursor, pageOffset, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Sets.cs b/src/StackExchange.Redis/MultiGroupDatabase.Sets.cs new file mode 100644 index 0000000000..f04df3eebb --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.Sets.cs @@ -0,0 +1,65 @@ +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // Set operations + public bool SetAdd(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetAdd(key, value, flags); + + public long SetAdd(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetAdd(key, values, flags); + + public RedisValue[] SetCombine(SetOperation operation, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetCombine(operation, first, second, flags); + + public RedisValue[] SetCombine(SetOperation operation, RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetCombine(operation, keys, flags); + + public long SetCombineAndStore(SetOperation operation, RedisKey destination, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetCombineAndStore(operation, destination, first, second, flags); + + public long SetCombineAndStore(SetOperation operation, RedisKey destination, RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetCombineAndStore(operation, destination, keys, flags); + + public bool SetContains(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetContains(key, value, flags); + + public bool[] SetContains(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetContains(key, values, flags); + + public long SetIntersectionLength(RedisKey[] keys, long limit = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetIntersectionLength(keys, limit, flags); + + public long SetLength(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetLength(key, flags); + + public RedisValue[] SetMembers(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetMembers(key, flags); + + public bool SetMove(RedisKey source, RedisKey destination, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetMove(source, destination, value, flags); + + public RedisValue SetPop(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetPop(key, flags); + + public RedisValue[] SetPop(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetPop(key, count, flags); + + public RedisValue SetRandomMember(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetRandomMember(key, flags); + + public RedisValue[] SetRandomMembers(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetRandomMembers(key, count, flags); + + public bool SetRemove(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetRemove(key, value, flags); + + public long SetRemove(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetRemove(key, values, flags); + + public System.Collections.Generic.IEnumerable SetScan(RedisKey key, RedisValue pattern, int pageSize, CommandFlags flags) + => GetActiveDatabase().SetScan(key, pattern, pageSize, flags); + + public System.Collections.Generic.IEnumerable SetScan(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetScan(key, pattern, pageSize, cursor, pageOffset, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.SortedSets.Async.cs b/src/StackExchange.Redis/MultiGroupDatabase.SortedSets.Async.cs new file mode 100644 index 0000000000..9f79cd4235 --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.SortedSets.Async.cs @@ -0,0 +1,127 @@ +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // SortedSet Async operations + public Task SortedSetAddAsync(RedisKey key, RedisValue member, double score, CommandFlags flags) + => GetActiveDatabase().SortedSetAddAsync(key, member, score, flags); + + public Task SortedSetAddAsync(RedisKey key, RedisValue member, double score, When when, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetAddAsync(key, member, score, when, flags); + + public Task SortedSetAddAsync(RedisKey key, RedisValue member, double score, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetAddAsync(key, member, score, when, flags); + + public Task SortedSetAddAsync(RedisKey key, SortedSetEntry[] values, CommandFlags flags) + => GetActiveDatabase().SortedSetAddAsync(key, values, flags); + + public Task SortedSetAddAsync(RedisKey key, SortedSetEntry[] values, When when, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetAddAsync(key, values, when, flags); + + public Task SortedSetAddAsync(RedisKey key, SortedSetEntry[] values, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetAddAsync(key, values, when, flags); + + public Task SortedSetCombineAsync(SetOperation operation, RedisKey[] keys, double[]? weights = null, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetCombineAsync(operation, keys, weights, aggregate, flags); + + public Task SortedSetCombineWithScoresAsync(SetOperation operation, RedisKey[] keys, double[]? weights = null, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetCombineWithScoresAsync(operation, keys, weights, aggregate, flags); + + public Task SortedSetCombineAndStoreAsync(SetOperation operation, RedisKey destination, RedisKey first, RedisKey second, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetCombineAndStoreAsync(operation, destination, first, second, aggregate, flags); + + public Task SortedSetCombineAndStoreAsync(SetOperation operation, RedisKey destination, RedisKey[] keys, double[]? weights = null, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetCombineAndStoreAsync(operation, destination, keys, weights, aggregate, flags); + + public Task SortedSetDecrementAsync(RedisKey key, RedisValue member, double value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetDecrementAsync(key, member, value, flags); + + public Task SortedSetIncrementAsync(RedisKey key, RedisValue member, double value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetIncrementAsync(key, member, value, flags); + + public Task SortedSetIncrementAsync(RedisKey key, RedisValue member, double value, ValueCondition when, CommandFlags flags) + => GetActiveDatabase().SortedSetIncrementAsync(key, member, value, when, flags); + + public Task SortedSetIntersectionLengthAsync(RedisKey[] keys, long limit = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetIntersectionLengthAsync(keys, limit, flags); + + public Task SortedSetLengthAsync(RedisKey key, double min = double.NegativeInfinity, double max = double.PositiveInfinity, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetLengthAsync(key, min, max, exclude, flags); + + public Task SortedSetLengthByValueAsync(RedisKey key, RedisValue min, RedisValue max, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetLengthByValueAsync(key, min, max, exclude, flags); + + public Task SortedSetRandomMemberAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRandomMemberAsync(key, flags); + + public Task SortedSetRandomMembersAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRandomMembersAsync(key, count, flags); + + public Task SortedSetRandomMembersWithScoresAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRandomMembersWithScoresAsync(key, count, flags); + + public Task SortedSetRangeByRankAsync(RedisKey key, long start = 0, long stop = -1, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRangeByRankAsync(key, start, stop, order, flags); + + public Task SortedSetRangeAndStoreAsync(RedisKey sourceKey, RedisKey destinationKey, RedisValue start, RedisValue stop, SortedSetOrder sortedSetOrder = SortedSetOrder.ByRank, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long? take = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRangeAndStoreAsync(sourceKey, destinationKey, start, stop, sortedSetOrder, exclude, order, skip, take, flags); + + public Task SortedSetRangeByRankWithScoresAsync(RedisKey key, long start = 0, long stop = -1, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRangeByRankWithScoresAsync(key, start, stop, order, flags); + + public Task SortedSetRangeByScoreAsync(RedisKey key, double start = double.NegativeInfinity, double stop = double.PositiveInfinity, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRangeByScoreAsync(key, start, stop, exclude, order, skip, take, flags); + + public Task SortedSetRangeByScoreWithScoresAsync(RedisKey key, double start = double.NegativeInfinity, double stop = double.PositiveInfinity, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRangeByScoreWithScoresAsync(key, start, stop, exclude, order, skip, take, flags); + + public Task SortedSetRangeByValueAsync(RedisKey key, RedisValue min, RedisValue max, Exclude exclude, long skip, long take = -1, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRangeByValueAsync(key, min, max, exclude, skip, take, flags); + + public Task SortedSetRangeByValueAsync(RedisKey key, RedisValue min = default, RedisValue max = default, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRangeByValueAsync(key, min, max, exclude, order, skip, take, flags); + + public Task SortedSetRankAsync(RedisKey key, RedisValue member, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRankAsync(key, member, order, flags); + + public Task SortedSetRemoveAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRemoveAsync(key, member, flags); + + public Task SortedSetRemoveAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRemoveAsync(key, members, flags); + + public Task SortedSetRemoveRangeByRankAsync(RedisKey key, long start, long stop, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRemoveRangeByRankAsync(key, start, stop, flags); + + public Task SortedSetRemoveRangeByScoreAsync(RedisKey key, double start, double stop, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRemoveRangeByScoreAsync(key, start, stop, exclude, flags); + + public Task SortedSetRemoveRangeByValueAsync(RedisKey key, RedisValue min, RedisValue max, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRemoveRangeByValueAsync(key, min, max, exclude, flags); + + public System.Collections.Generic.IAsyncEnumerable SortedSetScanAsync(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetScanAsync(key, pattern, pageSize, cursor, pageOffset, flags); + + public Task SortedSetScoreAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetScoreAsync(key, member, flags); + + public Task SortedSetScoresAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetScoresAsync(key, members, flags); + + public Task SortedSetPopAsync(RedisKey key, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetPopAsync(key, order, flags); + + public Task SortedSetPopAsync(RedisKey key, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetPopAsync(key, count, order, flags); + + public Task SortedSetPopAsync(RedisKey[] keys, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetPopAsync(keys, count, order, flags); + + public Task SortedSetUpdateAsync(RedisKey key, RedisValue member, double score, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetUpdateAsync(key, member, score, when, flags); + + public Task SortedSetUpdateAsync(RedisKey key, SortedSetEntry[] values, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetUpdateAsync(key, values, when, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.SortedSets.cs b/src/StackExchange.Redis/MultiGroupDatabase.SortedSets.cs new file mode 100644 index 0000000000..ad1a85aab5 --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.SortedSets.cs @@ -0,0 +1,130 @@ +using System; + +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // SortedSet operations + public bool SortedSetAdd(RedisKey key, RedisValue member, double score, CommandFlags flags) + => GetActiveDatabase().SortedSetAdd(key, member, score, flags); + + public bool SortedSetAdd(RedisKey key, RedisValue member, double score, When when, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetAdd(key, member, score, when, flags); + + public bool SortedSetAdd(RedisKey key, RedisValue member, double score, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetAdd(key, member, score, when, flags); + + public long SortedSetAdd(RedisKey key, SortedSetEntry[] values, CommandFlags flags) + => GetActiveDatabase().SortedSetAdd(key, values, flags); + + public long SortedSetAdd(RedisKey key, SortedSetEntry[] values, When when, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetAdd(key, values, when, flags); + + public long SortedSetAdd(RedisKey key, SortedSetEntry[] values, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetAdd(key, values, when, flags); + + public RedisValue[] SortedSetCombine(SetOperation operation, RedisKey[] keys, double[]? weights = null, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetCombine(operation, keys, weights, aggregate, flags); + + public SortedSetEntry[] SortedSetCombineWithScores(SetOperation operation, RedisKey[] keys, double[]? weights = null, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetCombineWithScores(operation, keys, weights, aggregate, flags); + + public long SortedSetCombineAndStore(SetOperation operation, RedisKey destination, RedisKey first, RedisKey second, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetCombineAndStore(operation, destination, first, second, aggregate, flags); + + public long SortedSetCombineAndStore(SetOperation operation, RedisKey destination, RedisKey[] keys, double[]? weights = null, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetCombineAndStore(operation, destination, keys, weights, aggregate, flags); + + public double SortedSetDecrement(RedisKey key, RedisValue member, double value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetDecrement(key, member, value, flags); + + public double SortedSetIncrement(RedisKey key, RedisValue member, double value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetIncrement(key, member, value, flags); + + public double? SortedSetIncrement(RedisKey key, RedisValue member, double value, ValueCondition when, CommandFlags flags) + => GetActiveDatabase().SortedSetIncrement(key, member, value, when, flags); + + public long SortedSetIntersectionLength(RedisKey[] keys, long limit = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetIntersectionLength(keys, limit, flags); + + public long SortedSetLength(RedisKey key, double min = double.NegativeInfinity, double max = double.PositiveInfinity, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetLength(key, min, max, exclude, flags); + + public long SortedSetLengthByValue(RedisKey key, RedisValue min, RedisValue max, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetLengthByValue(key, min, max, exclude, flags); + + public RedisValue SortedSetRandomMember(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRandomMember(key, flags); + + public RedisValue[] SortedSetRandomMembers(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRandomMembers(key, count, flags); + + public SortedSetEntry[] SortedSetRandomMembersWithScores(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRandomMembersWithScores(key, count, flags); + + public RedisValue[] SortedSetRangeByRank(RedisKey key, long start = 0, long stop = -1, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRangeByRank(key, start, stop, order, flags); + + public long SortedSetRangeAndStore(RedisKey sourceKey, RedisKey destinationKey, RedisValue start, RedisValue stop, SortedSetOrder sortedSetOrder = SortedSetOrder.ByRank, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long? take = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRangeAndStore(sourceKey, destinationKey, start, stop, sortedSetOrder, exclude, order, skip, take, flags); + + public SortedSetEntry[] SortedSetRangeByRankWithScores(RedisKey key, long start = 0, long stop = -1, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRangeByRankWithScores(key, start, stop, order, flags); + + public RedisValue[] SortedSetRangeByScore(RedisKey key, double start = double.NegativeInfinity, double stop = double.PositiveInfinity, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRangeByScore(key, start, stop, exclude, order, skip, take, flags); + + public SortedSetEntry[] SortedSetRangeByScoreWithScores(RedisKey key, double start = double.NegativeInfinity, double stop = double.PositiveInfinity, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRangeByScoreWithScores(key, start, stop, exclude, order, skip, take, flags); + + public RedisValue[] SortedSetRangeByValue(RedisKey key, RedisValue min, RedisValue max, Exclude exclude, long skip, long take = -1, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRangeByValue(key, min, max, exclude, skip, take, flags); + + public RedisValue[] SortedSetRangeByValue(RedisKey key, RedisValue min = default, RedisValue max = default, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRangeByValue(key, min, max, exclude, order, skip, take, flags); + + public long? SortedSetRank(RedisKey key, RedisValue member, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRank(key, member, order, flags); + + public bool SortedSetRemove(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRemove(key, member, flags); + + public long SortedSetRemove(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRemove(key, members, flags); + + public long SortedSetRemoveRangeByRank(RedisKey key, long start, long stop, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRemoveRangeByRank(key, start, stop, flags); + + public long SortedSetRemoveRangeByScore(RedisKey key, double start, double stop, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRemoveRangeByScore(key, start, stop, exclude, flags); + + public long SortedSetRemoveRangeByValue(RedisKey key, RedisValue min, RedisValue max, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetRemoveRangeByValue(key, min, max, exclude, flags); + + public System.Collections.Generic.IEnumerable SortedSetScan(RedisKey key, RedisValue pattern, int pageSize, CommandFlags flags) + => GetActiveDatabase().SortedSetScan(key, pattern, pageSize, flags); + + public System.Collections.Generic.IEnumerable SortedSetScan(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetScan(key, pattern, pageSize, cursor, pageOffset, flags); + + public double? SortedSetScore(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetScore(key, member, flags); + + public double?[] SortedSetScores(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetScores(key, members, flags); + + public SortedSetEntry? SortedSetPop(RedisKey key, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetPop(key, order, flags); + + public SortedSetEntry[] SortedSetPop(RedisKey key, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetPop(key, count, order, flags); + + public SortedSetPopResult SortedSetPop(RedisKey[] keys, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetPop(keys, count, order, flags); + + public bool SortedSetUpdate(RedisKey key, RedisValue member, double score, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetUpdate(key, member, score, when, flags); + + public long SortedSetUpdate(RedisKey key, SortedSetEntry[] values, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetUpdate(key, values, when, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Streams.Async.cs b/src/StackExchange.Redis/MultiGroupDatabase.Streams.Async.cs new file mode 100644 index 0000000000..584cabd0f9 --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.Streams.Async.cs @@ -0,0 +1,137 @@ +using System; +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // Stream Async operations + public Task StreamAutoClaimIdsOnlyAsync(RedisKey key, RedisValue consumerGroup, RedisValue claimingConsumer, long minIdleTimeInMs, RedisValue startAtId, int? count = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAutoClaimIdsOnlyAsync(key, consumerGroup, claimingConsumer, minIdleTimeInMs, startAtId, count, flags); + + public Task StreamAutoClaimAsync(RedisKey key, RedisValue consumerGroup, RedisValue claimingConsumer, long minIdleTimeInMs, RedisValue startAtId, int? count = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAutoClaimAsync(key, consumerGroup, claimingConsumer, minIdleTimeInMs, startAtId, count, flags); + + public Task StreamAddAsync(RedisKey key, RedisValue streamField, RedisValue streamValue, RedisValue? messageId = null, int? maxLength = null, bool useApproximateMaxLength = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAddAsync(key, streamField, streamValue, messageId, maxLength, useApproximateMaxLength, flags); + + public Task StreamAddAsync(RedisKey key, RedisValue streamField, RedisValue streamValue, RedisValue? messageId = null, long? maxLength = null, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAddAsync(key, streamField, streamValue, messageId, maxLength, useApproximateMaxLength, limit, trimMode, flags); + + public Task StreamAddAsync(RedisKey key, NameValueEntry[] streamPairs, RedisValue? messageId = null, int? maxLength = null, bool useApproximateMaxLength = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAddAsync(key, streamPairs, messageId, maxLength, useApproximateMaxLength, flags); + + public Task StreamAddAsync(RedisKey key, NameValueEntry[] streamPairs, RedisValue? messageId = null, long? maxLength = null, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAddAsync(key, streamPairs, messageId, maxLength, useApproximateMaxLength, limit, trimMode, flags); + + public Task StreamAddAsync(RedisKey key, RedisValue streamField, RedisValue streamValue, StreamIdempotentId idempotentId, long? maxLength = null, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAddAsync(key, streamField, streamValue, idempotentId, maxLength, useApproximateMaxLength, limit, trimMode, flags); + + public Task StreamAddAsync(RedisKey key, NameValueEntry[] streamPairs, StreamIdempotentId idempotentId, long? maxLength = null, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAddAsync(key, streamPairs, idempotentId, maxLength, useApproximateMaxLength, limit, trimMode, flags); + + public Task StreamClaimAsync(RedisKey key, RedisValue consumerGroup, RedisValue claimingConsumer, long minIdleTimeInMs, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamClaimAsync(key, consumerGroup, claimingConsumer, minIdleTimeInMs, messageIds, flags); + + public Task StreamClaimIdsOnlyAsync(RedisKey key, RedisValue consumerGroup, RedisValue claimingConsumer, long minIdleTimeInMs, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamClaimIdsOnlyAsync(key, consumerGroup, claimingConsumer, minIdleTimeInMs, messageIds, flags); + + public Task StreamConfigureAsync(RedisKey key, StreamConfiguration configuration, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamConfigureAsync(key, configuration, flags); + + public Task StreamConsumerGroupSetPositionAsync(RedisKey key, RedisValue groupName, RedisValue position, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamConsumerGroupSetPositionAsync(key, groupName, position, flags); + + public Task StreamConsumerInfoAsync(RedisKey key, RedisValue groupName, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamConsumerInfoAsync(key, groupName, flags); + + public Task StreamCreateConsumerGroupAsync(RedisKey key, RedisValue groupName, RedisValue? position, CommandFlags flags) + => GetActiveDatabase().StreamCreateConsumerGroupAsync(key, groupName, position, flags); + + public Task StreamCreateConsumerGroupAsync(RedisKey key, RedisValue groupName, RedisValue? position = null, bool createStream = true, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamCreateConsumerGroupAsync(key, groupName, position, createStream, flags); + + public Task StreamDeleteAsync(RedisKey key, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamDeleteAsync(key, messageIds, flags); + + public Task StreamDeleteAsync(RedisKey key, RedisValue[] messageIds, StreamTrimMode mode, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamDeleteAsync(key, messageIds, mode, flags); + + public Task StreamDeleteConsumerAsync(RedisKey key, RedisValue groupName, RedisValue consumerName, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamDeleteConsumerAsync(key, groupName, consumerName, flags); + + public Task StreamDeleteConsumerGroupAsync(RedisKey key, RedisValue groupName, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamDeleteConsumerGroupAsync(key, groupName, flags); + + public Task StreamGroupInfoAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamGroupInfoAsync(key, flags); + + public Task StreamInfoAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamInfoAsync(key, flags); + + public Task StreamLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamLengthAsync(key, flags); + + public Task StreamPendingAsync(RedisKey key, RedisValue groupName, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamPendingAsync(key, groupName, flags); + + public Task StreamPendingMessagesAsync(RedisKey key, RedisValue groupName, int count, RedisValue consumerName, RedisValue? minId = null, RedisValue? maxId = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamPendingMessagesAsync(key, groupName, count, consumerName, minId, maxId, flags); + + public Task StreamPendingMessagesAsync(RedisKey key, RedisValue groupName, int count, RedisValue consumerName, RedisValue? minId = null, RedisValue? maxId = null, long? idle = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamPendingMessagesAsync(key, groupName, count, consumerName, minId, maxId, idle, flags); + + public Task StreamRangeAsync(RedisKey key, RedisValue? minId = null, RedisValue? maxId = null, int? count = null, Order messageOrder = Order.Ascending, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamRangeAsync(key, minId, maxId, count, messageOrder, flags); + + public Task StreamReadAsync(RedisKey key, RedisValue position, int? count = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamReadAsync(key, position, count, flags); + + public Task StreamReadAsync(StreamPosition[] streamPositions, int? countPerStream = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamReadAsync(streamPositions, countPerStream, flags); + + public Task StreamReadGroupAsync(RedisKey key, RedisValue groupName, RedisValue consumerName, RedisValue? position, int? count, CommandFlags flags) + => GetActiveDatabase().StreamReadGroupAsync(key, groupName, consumerName, position, count, flags); + + public Task StreamReadGroupAsync(RedisKey key, RedisValue groupName, RedisValue consumerName, RedisValue? position = null, int? count = null, bool noAck = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamReadGroupAsync(key, groupName, consumerName, position, count, noAck, flags); + + public Task StreamReadGroupAsync(RedisKey key, RedisValue groupName, RedisValue consumerName, RedisValue? position = null, int? count = null, bool noAck = false, TimeSpan? blockingTimeout = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamReadGroupAsync(key, groupName, consumerName, position, count, noAck, blockingTimeout, flags); + + public Task StreamReadGroupAsync(StreamPosition[] streamPositions, RedisValue groupName, RedisValue consumerName, int? countPerStream, CommandFlags flags) + => GetActiveDatabase().StreamReadGroupAsync(streamPositions, groupName, consumerName, countPerStream, flags); + + public Task StreamReadGroupAsync(StreamPosition[] streamPositions, RedisValue groupName, RedisValue consumerName, int? countPerStream = null, bool noAck = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamReadGroupAsync(streamPositions, groupName, consumerName, countPerStream, noAck, flags); + + public Task StreamReadGroupAsync(StreamPosition[] streamPositions, RedisValue groupName, RedisValue consumerName, int? countPerStream = null, bool noAck = false, TimeSpan? blockingTimeout = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamReadGroupAsync(streamPositions, groupName, consumerName, countPerStream, noAck, blockingTimeout, flags); + + public Task StreamTrimAsync(RedisKey key, int maxLength, bool useApproximateMaxLength = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamTrimAsync(key, maxLength, useApproximateMaxLength, flags); + + public Task StreamTrimAsync(RedisKey key, long maxLength, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamTrimAsync(key, maxLength, useApproximateMaxLength, limit, trimMode, flags); + + public Task StreamTrimByMinIdAsync(RedisKey key, RedisValue minId, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamTrimByMinIdAsync(key, minId, useApproximateMaxLength, limit, trimMode, flags); + + public Task StreamAcknowledgeAsync(RedisKey key, RedisValue groupName, RedisValue messageId, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAcknowledgeAsync(key, groupName, messageId, flags); + + public Task StreamAcknowledgeAsync(RedisKey key, RedisValue groupName, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAcknowledgeAsync(key, groupName, messageIds, flags); + + public Task StreamNegativeAcknowledgeAsync(RedisKey key, RedisValue groupName, StreamNackMode mode, RedisValue messageId, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamNegativeAcknowledgeAsync(key, groupName, mode, messageId, flags); + + public Task StreamNegativeAcknowledgeAsync(RedisKey key, RedisValue groupName, StreamNackMode mode, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamNegativeAcknowledgeAsync(key, groupName, mode, messageIds, flags); + + public Task StreamAcknowledgeAndDeleteAsync(RedisKey key, RedisValue groupName, StreamTrimMode mode, RedisValue messageId, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAcknowledgeAndDeleteAsync(key, groupName, mode, messageId, flags); + + public Task StreamAcknowledgeAndDeleteAsync(RedisKey key, RedisValue groupName, StreamTrimMode mode, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAcknowledgeAndDeleteAsync(key, groupName, mode, messageIds, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Streams.cs b/src/StackExchange.Redis/MultiGroupDatabase.Streams.cs new file mode 100644 index 0000000000..a9061464f9 --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.Streams.cs @@ -0,0 +1,136 @@ +using System; + +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // Stream operations + public StreamAutoClaimIdsOnlyResult StreamAutoClaimIdsOnly(RedisKey key, RedisValue consumerGroup, RedisValue claimingConsumer, long minIdleTimeInMs, RedisValue startAtId, int? count = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAutoClaimIdsOnly(key, consumerGroup, claimingConsumer, minIdleTimeInMs, startAtId, count, flags); + + public StreamAutoClaimResult StreamAutoClaim(RedisKey key, RedisValue consumerGroup, RedisValue claimingConsumer, long minIdleTimeInMs, RedisValue startAtId, int? count = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAutoClaim(key, consumerGroup, claimingConsumer, minIdleTimeInMs, startAtId, count, flags); + + public RedisValue StreamAdd(RedisKey key, RedisValue streamField, RedisValue streamValue, RedisValue? messageId = null, int? maxLength = null, bool useApproximateMaxLength = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAdd(key, streamField, streamValue, messageId, maxLength, useApproximateMaxLength, flags); + + public RedisValue StreamAdd(RedisKey key, RedisValue streamField, RedisValue streamValue, RedisValue? messageId = null, long? maxLength = null, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAdd(key, streamField, streamValue, messageId, maxLength, useApproximateMaxLength, limit, trimMode, flags); + + public RedisValue StreamAdd(RedisKey key, NameValueEntry[] streamPairs, RedisValue? messageId = null, int? maxLength = null, bool useApproximateMaxLength = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAdd(key, streamPairs, messageId, maxLength, useApproximateMaxLength, flags); + + public RedisValue StreamAdd(RedisKey key, NameValueEntry[] streamPairs, RedisValue? messageId = null, long? maxLength = null, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAdd(key, streamPairs, messageId, maxLength, useApproximateMaxLength, limit, trimMode, flags); + + public RedisValue StreamAdd(RedisKey key, RedisValue streamField, RedisValue streamValue, StreamIdempotentId idempotentId, long? maxLength = null, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAdd(key, streamField, streamValue, idempotentId, maxLength, useApproximateMaxLength, limit, trimMode, flags); + + public RedisValue StreamAdd(RedisKey key, NameValueEntry[] streamPairs, StreamIdempotentId idempotentId, long? maxLength = null, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAdd(key, streamPairs, idempotentId, maxLength, useApproximateMaxLength, limit, trimMode, flags); + + public StreamEntry[] StreamClaim(RedisKey key, RedisValue consumerGroup, RedisValue claimingConsumer, long minIdleTimeInMs, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamClaim(key, consumerGroup, claimingConsumer, minIdleTimeInMs, messageIds, flags); + + public RedisValue[] StreamClaimIdsOnly(RedisKey key, RedisValue consumerGroup, RedisValue claimingConsumer, long minIdleTimeInMs, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamClaimIdsOnly(key, consumerGroup, claimingConsumer, minIdleTimeInMs, messageIds, flags); + + public void StreamConfigure(RedisKey key, StreamConfiguration configuration, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamConfigure(key, configuration, flags); + + public bool StreamConsumerGroupSetPosition(RedisKey key, RedisValue groupName, RedisValue position, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamConsumerGroupSetPosition(key, groupName, position, flags); + + public StreamConsumerInfo[] StreamConsumerInfo(RedisKey key, RedisValue groupName, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamConsumerInfo(key, groupName, flags); + + public bool StreamCreateConsumerGroup(RedisKey key, RedisValue groupName, RedisValue? position, CommandFlags flags) + => GetActiveDatabase().StreamCreateConsumerGroup(key, groupName, position, flags); + + public bool StreamCreateConsumerGroup(RedisKey key, RedisValue groupName, RedisValue? position = null, bool createStream = true, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamCreateConsumerGroup(key, groupName, position, createStream, flags); + + public long StreamDelete(RedisKey key, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamDelete(key, messageIds, flags); + + public long StreamDeleteConsumer(RedisKey key, RedisValue groupName, RedisValue consumerName, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamDeleteConsumer(key, groupName, consumerName, flags); + + public bool StreamDeleteConsumerGroup(RedisKey key, RedisValue groupName, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamDeleteConsumerGroup(key, groupName, flags); + + public StreamGroupInfo[] StreamGroupInfo(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamGroupInfo(key, flags); + + public StreamInfo StreamInfo(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamInfo(key, flags); + + public long StreamLength(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamLength(key, flags); + + public StreamPendingInfo StreamPending(RedisKey key, RedisValue groupName, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamPending(key, groupName, flags); + + public StreamPendingMessageInfo[] StreamPendingMessages(RedisKey key, RedisValue groupName, int count, RedisValue consumerName, RedisValue? minId = null, RedisValue? maxId = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamPendingMessages(key, groupName, count, consumerName, minId, maxId, flags); + + public StreamEntry[] StreamRange(RedisKey key, RedisValue? minId = null, RedisValue? maxId = null, int? count = null, Order messageOrder = Order.Ascending, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamRange(key, minId, maxId, count, messageOrder, flags); + + public StreamEntry[] StreamRead(RedisKey key, RedisValue position, int? count = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamRead(key, position, count, flags); + + public RedisStream[] StreamRead(StreamPosition[] streamPositions, int? countPerStream = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamRead(streamPositions, countPerStream, flags); + + public StreamEntry[] StreamReadGroup(RedisKey key, RedisValue groupName, RedisValue consumerName, RedisValue? position, int? count, CommandFlags flags) + => GetActiveDatabase().StreamReadGroup(key, groupName, consumerName, position, count, flags); + + public StreamEntry[] StreamReadGroup(RedisKey key, RedisValue groupName, RedisValue consumerName, RedisValue? position = null, int? count = null, bool noAck = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamReadGroup(key, groupName, consumerName, position, count, noAck, flags); + + public RedisStream[] StreamReadGroup(StreamPosition[] streamPositions, RedisValue groupName, RedisValue consumerName, int? countPerStream, CommandFlags flags) + => GetActiveDatabase().StreamReadGroup(streamPositions, groupName, consumerName, countPerStream, flags); + + public RedisStream[] StreamReadGroup(StreamPosition[] streamPositions, RedisValue groupName, RedisValue consumerName, int? countPerStream = null, bool noAck = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamReadGroup(streamPositions, groupName, consumerName, countPerStream, noAck, flags); + + public long StreamTrim(RedisKey key, int maxLength, bool useApproximateMaxLength = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamTrim(key, maxLength, useApproximateMaxLength, flags); + + public long StreamAcknowledge(RedisKey key, RedisValue groupName, RedisValue messageId, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAcknowledge(key, groupName, messageId, flags); + + public long StreamAcknowledge(RedisKey key, RedisValue groupName, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAcknowledge(key, groupName, messageIds, flags); + + public long StreamNegativeAcknowledge(RedisKey key, RedisValue groupName, StreamNackMode mode, RedisValue messageId, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamNegativeAcknowledge(key, groupName, mode, messageId, flags); + + public long StreamNegativeAcknowledge(RedisKey key, RedisValue groupName, StreamNackMode mode, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamNegativeAcknowledge(key, groupName, mode, messageIds, flags); + + public StreamTrimResult StreamAcknowledgeAndDelete(RedisKey key, RedisValue groupName, StreamTrimMode mode, RedisValue messageId, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAcknowledgeAndDelete(key, groupName, mode, messageId, flags); + + public StreamTrimResult[] StreamAcknowledgeAndDelete(RedisKey key, RedisValue groupName, StreamTrimMode mode, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamAcknowledgeAndDelete(key, groupName, mode, messageIds, flags); + + public StreamTrimResult[] StreamDelete(RedisKey key, RedisValue[] messageIds, StreamTrimMode mode, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamDelete(key, messageIds, mode, flags); + + public StreamPendingMessageInfo[] StreamPendingMessages(RedisKey key, RedisValue groupName, int count, RedisValue consumerName, RedisValue? minId = null, RedisValue? maxId = null, long? idle = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamPendingMessages(key, groupName, count, consumerName, minId, maxId, idle, flags); + + public StreamEntry[] StreamReadGroup(RedisKey key, RedisValue groupName, RedisValue consumerName, RedisValue? position = null, int? count = null, bool noAck = false, TimeSpan? blockingTimeout = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamReadGroup(key, groupName, consumerName, position, count, noAck, blockingTimeout, flags); + + public RedisStream[] StreamReadGroup(StreamPosition[] streamPositions, RedisValue groupName, RedisValue consumerName, int? countPerStream = null, bool noAck = false, TimeSpan? blockingTimeout = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamReadGroup(streamPositions, groupName, consumerName, countPerStream, noAck, blockingTimeout, flags); + + public long StreamTrim(RedisKey key, long maxLength, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamTrim(key, maxLength, useApproximateMaxLength, limit, trimMode, flags); + + public long StreamTrimByMinId(RedisKey key, RedisValue minId, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StreamTrimByMinId(key, minId, useApproximateMaxLength, limit, trimMode, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Strings.Async.cs b/src/StackExchange.Redis/MultiGroupDatabase.Strings.Async.cs new file mode 100644 index 0000000000..9dba49a82d --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.Strings.Async.cs @@ -0,0 +1,119 @@ +using System; +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // String Async operations + public Task StringAppendAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringAppendAsync(key, value, flags); + + public Task StringBitCountAsync(RedisKey key, long start, long end, CommandFlags flags) + => GetActiveDatabase().StringBitCountAsync(key, start, end, flags); + + public Task StringBitCountAsync(RedisKey key, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringBitCountAsync(key, start, end, indexType, flags); + + public Task StringBitOperationAsync(Bitwise operation, RedisKey destination, RedisKey first, RedisKey second = default, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringBitOperationAsync(operation, destination, first, second, flags); + + public Task StringBitOperationAsync(Bitwise operation, RedisKey destination, RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringBitOperationAsync(operation, destination, keys, flags); + + public Task StringBitPositionAsync(RedisKey key, bool bit, long start, long end, CommandFlags flags) + => GetActiveDatabase().StringBitPositionAsync(key, bit, start, end, flags); + + public Task StringBitPositionAsync(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringBitPositionAsync(key, bit, start, end, indexType, flags); + + public Task StringDecrementAsync(RedisKey key, long value = 1, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringDecrementAsync(key, value, flags); + + public Task StringDecrementAsync(RedisKey key, double value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringDecrementAsync(key, value, flags); + + public Task StringDeleteAsync(RedisKey key, ValueCondition when, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringDeleteAsync(key, when, flags); + + public Task StringDigestAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringDigestAsync(key, flags); + + public Task StringGetAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringGetAsync(key, flags); + + public Task StringGetAsync(RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringGetAsync(keys, flags); + + public Task?> StringGetLeaseAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringGetLeaseAsync(key, flags); + + public Task StringGetBitAsync(RedisKey key, long offset, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringGetBitAsync(key, offset, flags); + + public Task StringGetRangeAsync(RedisKey key, long start, long end, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringGetRangeAsync(key, start, end, flags); + + public Task StringGetSetAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringGetSetAsync(key, value, flags); + + public Task StringGetSetExpiryAsync(RedisKey key, TimeSpan? expiry, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringGetSetExpiryAsync(key, expiry, flags); + + public Task StringGetSetExpiryAsync(RedisKey key, DateTime expiry, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringGetSetExpiryAsync(key, expiry, flags); + + public Task StringGetDeleteAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringGetDeleteAsync(key, flags); + + public Task StringGetWithExpiryAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringGetWithExpiryAsync(key, flags); + + public Task StringIncrementAsync(RedisKey key, long value = 1, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringIncrementAsync(key, value, flags); + + public Task StringIncrementAsync(RedisKey key, double value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringIncrementAsync(key, value, flags); + + public Task StringLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringLengthAsync(key, flags); + + public Task StringLongestCommonSubsequenceAsync(RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringLongestCommonSubsequenceAsync(first, second, flags); + + public Task StringLongestCommonSubsequenceLengthAsync(RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringLongestCommonSubsequenceLengthAsync(first, second, flags); + + public Task StringLongestCommonSubsequenceWithMatchesAsync(RedisKey first, RedisKey second, long minLength = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringLongestCommonSubsequenceWithMatchesAsync(first, second, minLength, flags); + + public Task StringSetAsync(RedisKey key, RedisValue value, TimeSpan? expiry, When when) + => GetActiveDatabase().StringSetAsync(key, value, expiry, when); + + public Task StringSetAsync(RedisKey key, RedisValue value, TimeSpan? expiry, When when, CommandFlags flags) + => GetActiveDatabase().StringSetAsync(key, value, expiry, when, flags); + + public Task StringSetAsync(RedisKey key, RedisValue value, TimeSpan? expiry, bool keepTtl, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringSetAsync(key, value, expiry, keepTtl, when, flags); + + public Task StringSetAsync(RedisKey key, RedisValue value, Expiration expiry = default, ValueCondition when = default, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringSetAsync(key, value, expiry, when, flags); + + public Task StringSetAsync(System.Collections.Generic.KeyValuePair[] values, When when, CommandFlags flags) + => GetActiveDatabase().StringSetAsync(values, when, flags); + + public Task StringSetAsync(System.Collections.Generic.KeyValuePair[] values, When when = When.Always, Expiration expiry = default, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringSetAsync(values, when, expiry, flags); + + public Task StringSetAndGetAsync(RedisKey key, RedisValue value, TimeSpan? expiry, When when, CommandFlags flags) + => GetActiveDatabase().StringSetAndGetAsync(key, value, expiry, when, flags); + + public Task StringSetAndGetAsync(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringSetAndGetAsync(key, value, expiry, keepTtl, when, flags); + + public Task StringSetBitAsync(RedisKey key, long offset, bool bit, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringSetBitAsync(key, offset, bit, flags); + + public Task StringSetRangeAsync(RedisKey key, long offset, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringSetRangeAsync(key, offset, value, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Strings.cs b/src/StackExchange.Redis/MultiGroupDatabase.Strings.cs new file mode 100644 index 0000000000..ffab627875 --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.Strings.cs @@ -0,0 +1,118 @@ +using System; + +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // String operations + public long StringAppend(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringAppend(key, value, flags); + + public long StringBitCount(RedisKey key, long start, long end, CommandFlags flags) + => GetActiveDatabase().StringBitCount(key, start, end, flags); + + public long StringBitCount(RedisKey key, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringBitCount(key, start, end, indexType, flags); + + public long StringBitOperation(Bitwise operation, RedisKey destination, RedisKey first, RedisKey second = default, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringBitOperation(operation, destination, first, second, flags); + + public long StringBitOperation(Bitwise operation, RedisKey destination, RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringBitOperation(operation, destination, keys, flags); + + public long StringBitPosition(RedisKey key, bool bit, long start, long end, CommandFlags flags) + => GetActiveDatabase().StringBitPosition(key, bit, start, end, flags); + + public long StringBitPosition(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringBitPosition(key, bit, start, end, indexType, flags); + + public long StringDecrement(RedisKey key, long value = 1, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringDecrement(key, value, flags); + + public double StringDecrement(RedisKey key, double value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringDecrement(key, value, flags); + + public bool StringDelete(RedisKey key, ValueCondition when, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringDelete(key, when, flags); + + public ValueCondition? StringDigest(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringDigest(key, flags); + + public RedisValue StringGet(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringGet(key, flags); + + public RedisValue[] StringGet(RedisKey[] keys, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringGet(keys, flags); + + public Lease? StringGetLease(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringGetLease(key, flags); + + public bool StringGetBit(RedisKey key, long offset, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringGetBit(key, offset, flags); + + public RedisValue StringGetRange(RedisKey key, long start, long end, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringGetRange(key, start, end, flags); + + public RedisValue StringGetSet(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringGetSet(key, value, flags); + + public RedisValue StringGetSetExpiry(RedisKey key, TimeSpan? expiry, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringGetSetExpiry(key, expiry, flags); + + public RedisValue StringGetSetExpiry(RedisKey key, DateTime expiry, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringGetSetExpiry(key, expiry, flags); + + public RedisValue StringGetDelete(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringGetDelete(key, flags); + + public RedisValueWithExpiry StringGetWithExpiry(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringGetWithExpiry(key, flags); + + public long StringIncrement(RedisKey key, long value = 1, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringIncrement(key, value, flags); + + public double StringIncrement(RedisKey key, double value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringIncrement(key, value, flags); + + public long StringLength(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringLength(key, flags); + + public string? StringLongestCommonSubsequence(RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringLongestCommonSubsequence(first, second, flags); + + public long StringLongestCommonSubsequenceLength(RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringLongestCommonSubsequenceLength(first, second, flags); + + public LCSMatchResult StringLongestCommonSubsequenceWithMatches(RedisKey first, RedisKey second, long minLength = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringLongestCommonSubsequenceWithMatches(first, second, minLength, flags); + + public bool StringSet(RedisKey key, RedisValue value, TimeSpan? expiry, When when) + => GetActiveDatabase().StringSet(key, value, expiry, when); + + public bool StringSet(RedisKey key, RedisValue value, TimeSpan? expiry, When when, CommandFlags flags) + => GetActiveDatabase().StringSet(key, value, expiry, when, flags); + + public bool StringSet(RedisKey key, RedisValue value, TimeSpan? expiry, bool keepTtl, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringSet(key, value, expiry, keepTtl, when, flags); + + public bool StringSet(RedisKey key, RedisValue value, Expiration expiry = default, ValueCondition when = default, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringSet(key, value, expiry, when, flags); + + public bool StringSet(System.Collections.Generic.KeyValuePair[] values, When when, CommandFlags flags) + => GetActiveDatabase().StringSet(values, when, flags); + + public bool StringSet(System.Collections.Generic.KeyValuePair[] values, When when = When.Always, Expiration expiry = default, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringSet(values, when, expiry, flags); + + public RedisValue StringSetAndGet(RedisKey key, RedisValue value, TimeSpan? expiry, When when, CommandFlags flags) + => GetActiveDatabase().StringSetAndGet(key, value, expiry, when, flags); + + public RedisValue StringSetAndGet(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringSetAndGet(key, value, expiry, keepTtl, when, flags); + + public bool StringSetBit(RedisKey key, long offset, bool bit, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringSetBit(key, offset, bit, flags); + + public RedisValue StringSetRange(RedisKey key, long offset, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringSetRange(key, offset, value, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.VectorSets.Async.cs b/src/StackExchange.Redis/MultiGroupDatabase.VectorSets.Async.cs new file mode 100644 index 0000000000..f4c11eb247 --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.VectorSets.Async.cs @@ -0,0 +1,55 @@ +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // VectorSet Async operations + public Task VectorSetAddAsync(RedisKey key, VectorSetAddRequest request, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetAddAsync(key, request, flags); + + public Task VectorSetLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetLengthAsync(key, flags); + + public Task VectorSetDimensionAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetDimensionAsync(key, flags); + + public Task?> VectorSetGetApproximateVectorAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetGetApproximateVectorAsync(key, member, flags); + + public Task VectorSetGetAttributesJsonAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetGetAttributesJsonAsync(key, member, flags); + + public Task VectorSetInfoAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetInfoAsync(key, flags); + + public Task VectorSetContainsAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetContainsAsync(key, member, flags); + + public Task?> VectorSetGetLinksAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetGetLinksAsync(key, member, flags); + + public Task?> VectorSetGetLinksWithScoresAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetGetLinksWithScoresAsync(key, member, flags); + + public Task VectorSetRandomMemberAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetRandomMemberAsync(key, flags); + + public Task VectorSetRandomMembersAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetRandomMembersAsync(key, count, flags); + + public Task VectorSetRemoveAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetRemoveAsync(key, member, flags); + + public Task VectorSetSetAttributesJsonAsync(RedisKey key, RedisValue member, string attributesJson, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetSetAttributesJsonAsync(key, member, attributesJson, flags); + + public Task?> VectorSetSimilaritySearchAsync(RedisKey key, VectorSetSimilaritySearchRequest query, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetSimilaritySearchAsync(key, query, flags); + + public Task?> VectorSetRangeAsync(RedisKey key, RedisValue start = default, RedisValue end = default, long count = -1, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetRangeAsync(key, start, end, count, exclude, flags); + + public System.Collections.Generic.IAsyncEnumerable VectorSetRangeEnumerateAsync(RedisKey key, RedisValue start = default, RedisValue end = default, long count = 100, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetRangeEnumerateAsync(key, start, end, count, exclude, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.VectorSets.cs b/src/StackExchange.Redis/MultiGroupDatabase.VectorSets.cs new file mode 100644 index 0000000000..64470da4ee --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.VectorSets.cs @@ -0,0 +1,53 @@ +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // VectorSet operations + public bool VectorSetAdd(RedisKey key, VectorSetAddRequest request, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetAdd(key, request, flags); + + public long VectorSetLength(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetLength(key, flags); + + public int VectorSetDimension(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetDimension(key, flags); + + public Lease? VectorSetGetApproximateVector(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetGetApproximateVector(key, member, flags); + + public string? VectorSetGetAttributesJson(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetGetAttributesJson(key, member, flags); + + public VectorSetInfo? VectorSetInfo(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetInfo(key, flags); + + public bool VectorSetContains(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetContains(key, member, flags); + + public Lease? VectorSetGetLinks(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetGetLinks(key, member, flags); + + public Lease? VectorSetGetLinksWithScores(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetGetLinksWithScores(key, member, flags); + + public RedisValue VectorSetRandomMember(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetRandomMember(key, flags); + + public RedisValue[] VectorSetRandomMembers(RedisKey key, long count, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetRandomMembers(key, count, flags); + + public bool VectorSetRemove(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetRemove(key, member, flags); + + public bool VectorSetSetAttributesJson(RedisKey key, RedisValue member, string attributesJson, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetSetAttributesJson(key, member, attributesJson, flags); + + public Lease? VectorSetSimilaritySearch(RedisKey key, VectorSetSimilaritySearchRequest query, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetSimilaritySearch(key, query, flags); + + public Lease VectorSetRange(RedisKey key, RedisValue start = default, RedisValue end = default, long count = -1, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetRange(key, start, end, count, exclude, flags); + + public System.Collections.Generic.IEnumerable VectorSetRangeEnumerate(RedisKey key, RedisValue start = default, RedisValue end = default, long count = 100, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetRangeEnumerate(key, start, end, count, exclude, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.cs b/src/StackExchange.Redis/MultiGroupDatabase.cs new file mode 100644 index 0000000000..b4248c2dc4 --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.cs @@ -0,0 +1,95 @@ +using System; + +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase(MultiGroupMultiplexer parent, int database, object? asyncState) + : IDatabase +{ + public object? AsyncState => asyncState; + public int Database => database < 0 ? GetActiveDatabase().Database : database; + + public IConnectionMultiplexer Multiplexer => parent; + + // for high DB numbers this might allocate even for null async-state scenarios; unavoidable for now + private IDatabase GetActiveDatabase() => parent.Active.GetDatabase(database, asyncState); + + // Core methods + public IBatch CreateBatch(object? asyncState = null) + => GetActiveDatabase().CreateBatch(asyncState); + + public ITransaction CreateTransaction(object? asyncState = null) + => GetActiveDatabase().CreateTransaction(asyncState); + + public void KeyMigrate(RedisKey key, System.Net.EndPoint toServer, int toDatabase = 0, int timeoutMilliseconds = 0, MigrateOptions migrateOptions = MigrateOptions.None, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().KeyMigrate(key, toServer, toDatabase, timeoutMilliseconds, migrateOptions, flags); + + public RedisValue DebugObject(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().DebugObject(key, flags); + + public System.Net.EndPoint? IdentifyEndpoint(RedisKey key = default, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().IdentifyEndpoint(key, flags); + + public bool IsConnected(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().IsConnected(key, flags); + + public System.TimeSpan Ping(CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().Ping(flags); + + public long Publish(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().Publish(channel, message, flags); + + public RedisResult Execute(string command, params object[] args) + => GetActiveDatabase().Execute(command, args); + + public RedisResult Execute(string command, System.Collections.Generic.ICollection args, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().Execute(command, args, flags); + + public RedisResult ScriptEvaluate(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ScriptEvaluate(script, keys, values, flags); + + public RedisResult ScriptEvaluate(byte[] hash, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ScriptEvaluate(hash, keys, values, flags); + + public RedisResult ScriptEvaluate(LuaScript script, object? parameters = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ScriptEvaluate(script, parameters, flags); + + public RedisResult ScriptEvaluate(LoadedLuaScript script, object? parameters = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ScriptEvaluate(script, parameters, flags); + + public RedisResult ScriptEvaluateReadOnly(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ScriptEvaluateReadOnly(script, keys, values, flags); + + public RedisResult ScriptEvaluateReadOnly(byte[] hash, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ScriptEvaluateReadOnly(hash, keys, values, flags); + + public bool LockExtend(RedisKey key, RedisValue value, TimeSpan expiry, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().LockExtend(key, value, expiry, flags); + + public RedisValue LockQuery(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().LockQuery(key, flags); + + public bool LockRelease(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().LockRelease(key, value, flags); + + public bool LockTake(RedisKey key, RedisValue value, TimeSpan expiry, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().LockTake(key, value, expiry, flags); + + public RedisValue[] Sort(RedisKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, RedisValue by = default, RedisValue[]? get = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().Sort(key, skip, take, order, sortType, by, get, flags); + + public long SortAndStore(RedisKey destination, RedisKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, RedisValue by = default, RedisValue[]? get = null, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortAndStore(destination, key, skip, take, order, sortType, by, get, flags); + + // IRedisAsync methods + public bool TryWait(System.Threading.Tasks.Task task) + => GetActiveDatabase().TryWait(task); + + public void Wait(System.Threading.Tasks.Task task) + => GetActiveDatabase().Wait(task); + + public T Wait(System.Threading.Tasks.Task task) + => GetActiveDatabase().Wait(task); + + public void WaitAll(params System.Threading.Tasks.Task[] tasks) + => GetActiveDatabase().WaitAll(tasks); +} diff --git a/src/StackExchange.Redis/MultiGroupMultiplexer.cs b/src/StackExchange.Redis/MultiGroupMultiplexer.cs new file mode 100644 index 0000000000..ab85d688d2 --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupMultiplexer.cs @@ -0,0 +1,1136 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using RESPite; +using StackExchange.Redis.Maintenance; +using StackExchange.Redis.Profiling; + +namespace StackExchange.Redis; + +public partial class ConnectionMultiplexer +{ + /// + /// Creates a new instance that manages connections to multiple + /// redundant configurations, based on their availability and relative . + /// + /// The initial configurations to connect to. + /// Additional options for configuring this group. + /// The to log to. +#pragma warning disable RS0026 + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + public static Task ConnectGroupAsync(ConnectionGroupMember[] members, MultiGroupOptions? options = null, TextWriter? log = null) +#pragma warning restore RS0026 + { + // create a defensive copy of the array; we don't want callers being able to radically swap things! + members = (ConnectionGroupMember[])members.Clone(); + options ??= MultiGroupOptions.Default; + options.Freeze(); + return MultiGroupMultiplexer.ConnectAsync(members, options, log); + } + + /// + /// Creates a new instance that manages connections to multiple + /// redundant configurations, based on their availability and relative . + /// + /// An initial configuration to connect to. + /// An additional initial configuration to connect to. + /// Additional options for configuring this group. + /// The to log to. + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +#pragma warning disable RS0026 + public static Task ConnectGroupAsync( + ConnectionGroupMember member0, + ConnectionGroupMember member1, + MultiGroupOptions? options = null, + TextWriter? log = null) +#pragma warning restore RS0026 + { + options ??= MultiGroupOptions.Default; + options.Freeze(); + return MultiGroupMultiplexer.ConnectAsync([member0, member1], options, log); + } +} + +/// +/// A configured member of a . +/// +[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +#pragma warning disable RS0016, RS0026 +public sealed partial class ConnectionGroupMember(ConfigurationOptions configuration, string name = "") +#pragma warning restore RS0016, RS0026 +{ + /// + /// Create a new from a configuration string. + /// +#pragma warning disable RS0016, RS0026 + public ConnectionGroupMember(string configuration, string name = "") : this( + ConfigurationOptions.Parse(configuration)) +#pragma warning restore RS0016, RS0026 + { + } + + internal ConfigurationOptions Configuration => configuration; + + private int _activated; // each member can only be activated once + + /// + public override string ToString() => Name; + + private ConnectionMultiplexer? _muxer; + + internal ConnectionMultiplexer Multiplexer => _muxer ?? ThrowNoMuxer(); + + [DoesNotReturn] + private static ConnectionMultiplexer ThrowNoMuxer() => + throw new InvalidOperationException("Member is not connected."); + + // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract + internal void SetMultiplexer(ConnectionMultiplexer muxer) + => Interlocked.Exchange(ref _muxer, muxer ?? ThrowNoMuxer()); + + internal ConnectionMultiplexer? ClearMultiplexer() => Interlocked.Exchange(ref _muxer, null); + + internal void Init(int index) + { + // add a name if not provided + if (string.IsNullOrWhiteSpace(Name)) + { + var ep = Configuration.EndPoints.FirstOrDefault(); + if (ep is null) + { + Name = index.ToString(); + } + else + { + Name = Format.ToString(ep); + } + } + + // check not already attached + if (Interlocked.CompareExchange(ref _activated, 1, 0) != 0) + { + throw new InvalidOperationException( + $"Member '{Name}' is already associated with a group, and cannot be reused."); + } + } + + /// + /// Indicates whether this group is currently connected. + /// + public bool IsConnected { get; private set; } + + /// + /// The name of this group member. + /// + public string Name { get; private set; } = name; + + /// + /// The relative weight of this group member; higher is preferred. + /// + public double Weight + { + // avoid "tearing", since we can't rule out this being updated concurrently, and the runtime + // only guarantees atomicity for 32-bit reads/writes + get => BitConverter.Int64BitsToDouble(Interlocked.Read(ref _weight64)); + set => Interlocked.Exchange(ref _weight64, BitConverter.DoubleToInt64Bits(value)); + } + + internal bool ExplicitOverride { get; set; } + + private long _weight64 = BitConverter.DoubleToInt64Bits(1.0); + + /// + /// The measured latency to this member. + /// + public TimeSpan Latency => _latencyTicks is uint.MaxValue ? TimeSpan.MaxValue : TimeSpan.FromTicks(_latencyTicks); + + private uint _latencyTicks = uint.MaxValue; + + internal void SetLatency(uint ticks) => _latencyTicks = ticks; + + internal static uint ToLatencyTicks(TimeSpan latency) + { + long ticks = latency.Ticks; + if (ticks <= 0) + { + return 0; + } + return ticks > uint.MaxValue ? uint.MaxValue : (uint)ticks; + } + + internal void SetLatency(TimeSpan latency) => SetLatency(ToLatencyTicks(latency)); + + internal static ConnectionGroupMember? Select(ConnectionGroupMember? x, ConnectionGroupMember? y) + { + if (x is null) return y; + if (y is null) return x; + + // always prefer a connected endpoint + bool xc = x.IsConnected, yc = y.IsConnected; + if (xc != yc) return xc ? x : y; + + // prefer manual override if only one is overridden + xc = x.ExplicitOverride; + yc = y.ExplicitOverride; + if (xc != yc) return xc ? x : y; + + // prefer higher weight + double xw = x.Weight, yw = y.Weight; + // ReSharper disable once CompareOfFloatsByEqualityOperator + if (xw != yw) return xw > yw ? x : y; + + // then by latency + uint xl = x._latencyTicks, yl = y._latencyTicks; + return xl <= yl ? x : y; + } + + internal GroupConnectionChangedEventArgs.ChangeType UpdateState(HealthCheck.HealthCheckResult result) + { + bool isConnected; + if (_muxer is { IsConnected: true } muxer) + { + isConnected = result is not HealthCheck.HealthCheckResult.Unhealthy; + SetLatency(muxer.UpdateLatency()); + } + else + { + isConnected = false; + } + + var oldConnected = IsConnected; + IsConnected = isConnected; + + return isConnected == oldConnected ? GroupConnectionChangedEventArgs.ChangeType.Unknown + : isConnected ? GroupConnectionChangedEventArgs.ChangeType.Reconnected + : GroupConnectionChangedEventArgs.ChangeType.Disconnected; + } +} + +internal sealed partial class MultiGroupMultiplexer : IConnectionGroup +{ + private ConnectionMultiplexer? _active; + private ConnectionGroupMember[] _members; + + public override string ToString() + { + var muxer = _active; + ConnectionGroupMember? member = null; + if (muxer is not null) + { + foreach (var m in _members) + { + if (ReferenceEquals(muxer, m.Multiplexer)) + { + member = m; + break; + } + } + } + + return member is null ? "No active connection" : $"Connected to {member.Name}"; + } + + public ReadOnlySpan GetMembers() => _members; + + internal ConnectionMultiplexer Active + { + get + { + return _active ?? Throw(); + + [DoesNotReturn] + static ConnectionMultiplexer Throw() => + throw new InvalidOperationException("All connections are unavailable."); + } + } + + private ConnectionGroupMember? GetActiveMember() + { + var active = _active; + foreach (var member in _members) + { + if (ReferenceEquals(active, member.Multiplexer)) + { + return member; + } + } + + return null; + } + ConnectionGroupMember? IConnectionGroup.ActiveMember => GetActiveMember(); + + internal ConnectionGroupMember ActiveMember + { + get + { + return GetActiveMember() ?? Throw(); + + [DoesNotReturn] + static ConnectionGroupMember Throw() => + throw new InvalidOperationException("All connections are unavailable."); + } + } + + internal static async Task ConnectAsync(ConnectionGroupMember[] members, MultiGroupOptions options, TextWriter? log) + { + for (int i = 0; i < members.Length; i++) + { + members[i].Init(i); + } + + var pending = new Task[members.Length]; + for (int i = 0; i < members.Length; i++) + { + var config = members[i].Configuration; + config.AbortOnConnectFail = false; + config.HeartbeatConsistencyChecks = true; + pending[i] = ConnectionMultiplexer.ConnectAsync(config, log); + } + + for (int i = 0; i < pending.Length; i++) + { + var muxer = await pending[i].ConfigureAwait(false); + members[i].SetMultiplexer(muxer); + } + + // run initial healthcheck and begin + var result = new MultiGroupMultiplexer(members, options); + await TryHealthCheckAndSelectPreferredGroupAsync(result).ForAwait(); + result.StartPolling(); + return result; + } + + private readonly MultiGroupOptions _options; + private MultiGroupMultiplexer(ConnectionGroupMember[] members, MultiGroupOptions options) + { + _options = options; + _members = members; + _active = null; + } + + internal static async Task TryHealthCheckAndSelectPreferredGroupAsync(object? target) + { + if (target is MultiGroupMultiplexer typed) + { + try + { + if (typed.IsDisposed) return false; + await typed.RunHealthCheckAsync().ForAwait(); + typed.SelectPreferredGroup(); + } + catch (Exception ex) + { + typed.OnInternalError(ex, origin: "update group"); + } + return true; // even if we fault: try again + } + return false; + } + + private void StartPolling() + { + // use a weak-ref to avoid the loop keeping the object alive + _ = Task.Run(() => PollAsync(new(this))); + + static async Task PollAsync(WeakReference weakRef) + { + while (TryGetHealthCheck(weakRef.Target, out var interval)) + { + await Task.Delay(interval).ForAwait(); + if (!await TryHealthCheckAndSelectPreferredGroupAsync(weakRef.Target).ForAwait()) break; + } + } + + static bool TryGetHealthCheck(object? target, out TimeSpan interval) + { + if (target is MultiGroupMultiplexer typed + && typed._options.HealthCheck is { } healthCheck) + { + interval = healthCheck.Interval; + return interval > TimeSpan.Zero & interval != TimeSpan.MaxValue; + } + + interval = TimeSpan.Zero; + return false; + } + } + + internal bool IsDisposed => _disposed; + + private Task[]? _reusableHealthCheckBuffer; + + internal async Task RunHealthCheckAsync() + { + if (_disposed) return; + var healthCheck = _options.HealthCheck; + var members = _members; + var pending = HealthCheck.GetReusablePending(ref _reusableHealthCheckBuffer, members.Length); + for (int i = 0; i < members.Length; i++) + { + pending[i] = healthCheck.CheckHealthAsync(members[i].Multiplexer); + } + + await Task.WhenAll(pending).TimeoutAfter(healthCheck.TotalTimeoutMillis()).ForAwait(); + for (int i = 0; i < pending.Length; i++) + { + HealthCheck.HealthCheckResult result; + if (pending[i].IsCompletedSuccessfully) + { + result = await pending[i].ForAwait(); + } + else + { + _ = pending[i].ObserveErrors(); + result = HealthCheck.HealthCheckResult.Unhealthy; + } + + var delta = members[i].UpdateState(result); + if (delta != GroupConnectionChangedEventArgs.ChangeType.Unknown) + { + OnConnectionChanged(delta, members[i]); + } + } + + HealthCheck.PutReusablePending(ref _reusableHealthCheckBuffer, ref pending); + } + + internal void SelectPreferredGroup() + { + if (_disposed) return; + var previousMuxer = _active; + ConnectionGroupMember? preferredMember = null, previousMember = null; + var members = _members; + foreach (var member in members) + { + if (previousMember is null && ReferenceEquals(member.Multiplexer, previousMuxer)) + { + previousMember = member; + } + + if (member.IsConnected) + { + preferredMember = ConnectionGroupMember.Select(preferredMember, member); + } + } + + _active = preferredMember?.Multiplexer; + + if (preferredMember is not null && !ReferenceEquals(preferredMember, previousMember)) + { + OnConnectionChanged( + GroupConnectionChangedEventArgs.ChangeType.ActiveChanged, + preferredMember, + previousMember); + } + } + + private List DropAll() + { + _active = null; + var members = Interlocked.Exchange(ref _members, []); + if (members.Length is 0) return []; + var muxers = new List(members.Length); + foreach (var member in members) + { + var muxer = member.ClearMultiplexer(); + if (muxer is not null) muxers.Add(muxer); + } + + return muxers; + } + + private bool _disposed; + public void Dispose() + { + _disposed = true; + foreach (var muxer in DropAll()) + { + muxer.Dispose(); + } + } + + public async ValueTask DisposeAsync() + { + _disposed = true; + foreach (var muxer in DropAll()) + { + await muxer.DisposeAsync(); + } + } + + public string ClientName => Active.ClientName; + public string Configuration => Active.Configuration; + public int TimeoutMilliseconds => Active.TimeoutMilliseconds; + + public long OperationCount + { + get + { + long count = 0; + foreach (var member in _members) + { + count += member.Multiplexer.OperationCount; + } + + return count; + } + } + + [Obsolete] + public bool PreserveAsyncOrder + { + get => Active.PreserveAsyncOrder; + set => Active.PreserveAsyncOrder = value; + } + + public bool IsConnected => Active.IsConnected; + public bool IsConnecting => Active.IsConnecting; + + [Obsolete] + public bool IncludeDetailInExceptions + { + get => Active.IncludeDetailInExceptions; + set => Active.IncludeDetailInExceptions = value; + } + + public int StormLogThreshold + { + get => Active.StormLogThreshold; + set => Active.StormLogThreshold = value; + } + + private Func? _profilingSessionProvider; + + public void RegisterProfiler(Func profilingSessionProvider) + { + _profilingSessionProvider = profilingSessionProvider; + foreach (var member in _members) + { + member.Multiplexer.RegisterProfiler(profilingSessionProvider); + } + } + + public ServerCounters GetCounters() => Active.GetCounters(); + + private EventHandler? _errorMessage; + + public event EventHandler? ErrorMessage + { + add + { + if (AddHandler(ref _errorMessage, value)) + { + foreach (var member in _members) + { + member.Multiplexer.ErrorMessage += value; + } + } + } + remove + { + if (RemoveHandler(ref _errorMessage, value)) + { + foreach (var member in _members) + { + member.Multiplexer.ErrorMessage -= value; + } + } + } + } + + /// + /// Add a handler, and return true if this is the *first* handler, which means we should subscribe the dependents. + /// + private static bool AddHandler(ref T? field, T? value) where T : Delegate + { + if (value is null) return false; + while (true) // loop until we win (competition) + { + var oldValue = field; + var newValue = oldValue is null ? value : (T)Delegate.Combine(oldValue, value); + + if (ReferenceEquals(Interlocked.CompareExchange(ref field, newValue, oldValue), oldValue)) + { + return oldValue is null; + } + } + } + + /// + /// Remove a handler, and return true if this is the *last* handler, which means we should unsubscribe the dependents. + /// + private static bool RemoveHandler(ref T? field, T? value) where T : Delegate + { + if (value is null) return false; + while (true) // loop until we win (competition) + { + var oldValue = field; + var newValue = oldValue is null ? null : (T?)Delegate.Remove(oldValue, value); + + if (ReferenceEquals(Interlocked.CompareExchange(ref field, newValue, oldValue), oldValue)) + { + return newValue is null; + } + } + } + + /// + /// Subscribe a child multiplexer to all local event handlers that have subscribers. + /// + private void AddEventHandlers(ConnectionMultiplexer muxer) + { + muxer.ErrorMessage += _errorMessage; + muxer.ConnectionFailed += _connectionFailed; + muxer.InternalError += _internalError; + muxer.ConnectionRestored += _connectionRestored; + muxer.ConfigurationChanged += _configurationChanged; + muxer.ConfigurationChangedBroadcast += _configurationChangedBroadcast; + muxer.ServerMaintenanceEvent += _serverMaintenanceEvent; + muxer.HashSlotMoved += _hashSlotMoved; + } + + /// + /// Unsubscribe a child multiplexer from all local event handlers. + /// + private void RemoveEventHandlers(ConnectionMultiplexer? muxer) + { + if (muxer is null) return; + muxer.ErrorMessage -= _errorMessage; + muxer.ConnectionFailed -= _connectionFailed; + muxer.InternalError -= _internalError; + muxer.ConnectionRestored -= _connectionRestored; + muxer.ConfigurationChanged -= _configurationChanged; + muxer.ConfigurationChangedBroadcast -= _configurationChangedBroadcast; + muxer.ServerMaintenanceEvent -= _serverMaintenanceEvent; + muxer.HashSlotMoved -= _hashSlotMoved; + } + + private EventHandler? _connectionFailed; + + public event EventHandler? ConnectionFailed + { + add + { + if (AddHandler(ref _connectionFailed, value)) + { + foreach (var member in _members) + { + member.Multiplexer.ConnectionFailed += value; + } + } + } + remove + { + if (RemoveHandler(ref _connectionFailed, value)) + { + foreach (var member in _members) + { + member.Multiplexer.ConnectionFailed -= value; + } + } + } + } + + private EventHandler? _internalError; + + public event EventHandler? InternalError + { + add + { + if (AddHandler(ref _internalError, value)) + { + foreach (var member in _members) + { + member.Multiplexer.InternalError += value; + } + } + } + remove + { + if (RemoveHandler(ref _internalError, value)) + { + foreach (var member in _members) + { + member.Multiplexer.InternalError -= value; + } + } + } + } + + private EventHandler? _connectionRestored; + + public event EventHandler? ConnectionRestored + { + add + { + if (AddHandler(ref _connectionRestored, value)) + { + foreach (var member in _members) + { + member.Multiplexer.ConnectionRestored += value; + } + } + } + remove + { + if (RemoveHandler(ref _connectionRestored, value)) + { + foreach (var member in _members) + { + member.Multiplexer.ConnectionRestored -= value; + } + } + } + } + + private EventHandler? _configurationChanged; + + public event EventHandler? ConfigurationChanged + { + add + { + if (AddHandler(ref _configurationChanged, value)) + { + foreach (var member in _members) + { + member.Multiplexer.ConfigurationChanged += value; + } + } + } + remove + { + if (RemoveHandler(ref _configurationChanged, value)) + { + foreach (var member in _members) + { + member.Multiplexer.ConfigurationChanged -= value; + } + } + } + } + + private EventHandler? _configurationChangedBroadcast; + + public event EventHandler? ConfigurationChangedBroadcast + { + add + { + if (AddHandler(ref _configurationChangedBroadcast, value)) + { + foreach (var member in _members) + { + member.Multiplexer.ConfigurationChangedBroadcast += value; + } + } + } + remove + { + if (RemoveHandler(ref _configurationChangedBroadcast, value)) + { + foreach (var member in _members) + { + member.Multiplexer.ConfigurationChangedBroadcast -= value; + } + } + } + } + + private EventHandler? _serverMaintenanceEvent; + + public event EventHandler? ServerMaintenanceEvent + { + add + { + if (AddHandler(ref _serverMaintenanceEvent, value)) + { + foreach (var member in _members) + { + member.Multiplexer.ServerMaintenanceEvent += value; + } + } + } + remove + { + if (RemoveHandler(ref _serverMaintenanceEvent, value)) + { + foreach (var member in _members) + { + member.Multiplexer.ServerMaintenanceEvent -= value; + } + } + } + } + + public EndPoint[] GetEndPoints(bool configuredOnly = false) => Active.GetEndPoints(configuredOnly); + + public void Wait(Task task) => Active.Wait(task); + + public T Wait(Task task) => Active.Wait(task); + + public void WaitAll(params Task[] tasks) => Active.WaitAll(tasks); + + private EventHandler? _hashSlotMoved; + + public event EventHandler? HashSlotMoved + { + add + { + if (AddHandler(ref _hashSlotMoved, value)) + { + foreach (var member in _members) + { + member.Multiplexer.HashSlotMoved += value; + } + } + } + remove + { + if (RemoveHandler(ref _hashSlotMoved, value)) + { + foreach (var member in _members) + { + member.Multiplexer.HashSlotMoved -= value; + } + } + } + } + + public int HashSlot(RedisKey key) => Active.HashSlot(key); + + private ISubscriber? _defaultSubscriber; + + public ISubscriber GetSubscriber(object? asyncState = null) + { + if (asyncState is null) return _defaultSubscriber ??= new MultiGroupSubscriber(this, null); + return new MultiGroupSubscriber(this, asyncState); + } + + public IDatabase GetDatabase(int db = -1, object? asyncState = null) + { + if (asyncState is null & db >= -1 & db <= ConnectionMultiplexer.MaxCachedDatabaseInstance) + { + return _databases[db + 1] ??= new MultiGroupDatabase(this, db, null); + } + + return new MultiGroupDatabase(this, db, asyncState); + } + + private readonly IDatabase?[] _databases = new IDatabase?[ConnectionMultiplexer.MaxCachedDatabaseInstance + 2]; + + public IServer GetServer(string host, int port, object? asyncState = null) + { + Exception ex; + try + { + // try "active" first, and preserve the exception + return Active.GetServer(host, port, asyncState); + } + catch (Exception e) + { + ex = e; + } + + foreach (var member in _members) + { + try + { + return member.Multiplexer.GetServer(host, port, asyncState); + } + catch (Exception e) { Debug.WriteLine(e.Message); } + } + + throw ex; + } + + public IServer GetServer(string hostAndPort, object? asyncState = null) + { + Exception ex; + try + { + // try "active" first, and preserve the exception + return Active.GetServer(hostAndPort, asyncState); + } + catch (Exception e) + { + ex = e; + } + + foreach (var member in _members) + { + try + { + return member.Multiplexer.GetServer(hostAndPort, asyncState); + } + catch (Exception e) { Debug.WriteLine(e.Message); } + } + + throw ex; + } + + public IServer GetServer(IPAddress host, int port) + { + Exception ex; + try + { + // try "active" first, and preserve the exception + return Active.GetServer(host, port); + } + catch (Exception e) + { + ex = e; + } + + foreach (var member in _members) + { + try + { + return member.Multiplexer.GetServer(host, port); + } + catch (Exception e) { Debug.WriteLine(e.Message); } + } + + throw ex; + } + + public IServer GetServer(EndPoint endpoint, object? asyncState = null) + { + Exception ex; + try + { + // try "active" first, and preserve the exception + return Active.GetServer(endpoint, asyncState); + } + catch (Exception e) + { + ex = e; + } + + foreach (var member in _members) + { + try + { + return member.Multiplexer.GetServer(endpoint, asyncState); + } + catch (Exception e) { Debug.WriteLine(e.Message); } + } + + throw ex; + } + + public IServer GetServer(RedisKey key, object? asyncState = null, CommandFlags flags = CommandFlags.None) => + Active.GetServer(key, asyncState, flags); + + public IServer[] GetServers() => Active.GetServers(); + + public Task ConfigureAsync(TextWriter? log = null) => Active.ConfigureAsync(log); + + public bool Configure(TextWriter? log = null) => Active.Configure(log); + + public string GetStatus() => Active.GetStatus(); + + public void GetStatus(TextWriter log) => Active.GetStatus(log); + + public void Close(bool allowCommandsToComplete = true) + { + _disposed = true; + foreach (var member in DropAll()) + { + member.Close(allowCommandsToComplete); + } + } + + public async Task CloseAsync(bool allowCommandsToComplete = true) + { + _disposed = true; + foreach (var member in DropAll()) + { + await member.CloseAsync(allowCommandsToComplete); + } + } + + public string? GetStormLog() => Active.GetStormLog(); + + public void ResetStormLog() => Active.ResetStormLog(); + + public long PublishReconfigure(CommandFlags flags = CommandFlags.None) => Active.PublishReconfigure(flags); + + public Task PublishReconfigureAsync(CommandFlags flags = CommandFlags.None) => + Active.PublishReconfigureAsync(flags); + + public int GetHashSlot(RedisKey key) => Active.GetHashSlot(key); + + public void ExportConfiguration(Stream destination, ExportOptions options = ExportOptions.All) => + Active.ExportConfiguration(destination, options); + + private readonly HashSet _suffixes = new(); // in case we need to add to a new muxer + public void AddLibraryNameSuffix(string suffix) + { + if (string.IsNullOrWhiteSpace(suffix)) return; // trivial + bool isNew; + lock (_suffixes) + { + isNew = _suffixes.Add(suffix); + } + + if (isNew) + { + foreach (var member in _members) + { + member.Multiplexer.AddLibraryNameSuffix(suffix); + } + } + } + + public event EventHandler? ConnectionChanged; + + private void OnConnectionChanged( + GroupConnectionChangedEventArgs.ChangeType changeType, + ConnectionGroupMember member, + ConnectionGroupMember? previousMember = null) + { + var handler = ConnectionChanged; + if (handler is not null) + { + new GroupConnectionChangedEventArgs(changeType, member, previousMember) + .CompleteAsWorker(handler, this); + } + } + + public async Task AddAsync(ConnectionGroupMember member, TextWriter? log = null) + { + // connect + member.Init(_members.Length); + member.Configuration.HeartbeatConsistencyChecks = true; + var muxer = await ConnectionMultiplexer.ConnectAsync(member.Configuration, log).ConfigureAwait(false); + member.SetMultiplexer(muxer); + var health = await _options.HealthCheck.CheckHealthAsync(muxer).ConfigureAwait(false); + member.UpdateState(health); + + // apply any shared hooks + AddEventHandlers(muxer); + if (_profilingSessionProvider is not null) muxer.RegisterProfiler(_profilingSessionProvider); + lock (_suffixes) + { + foreach (var suffix in _suffixes) + { + muxer.AddLibraryNameSuffix(suffix); + } + } + + // update the members array + while (true) + { + var arr = _members; + var newArr = new ConnectionGroupMember[arr.Length + 1]; + Array.Copy(arr, 0, newArr, 0, arr.Length); + newArr[arr.Length] = member; + if (Interlocked.CompareExchange(ref _members, newArr, arr) == arr) break; + } + + OnConnectionChanged(GroupConnectionChangedEventArgs.ChangeType.Added, member); + SelectPreferredGroup(); + + // pub/sub + await AddPubSubHandlersAsync(member).ConfigureAwait(false); + } + + public bool TryFailoverTo(ConnectionGroupMember? member) + { + if (member is null) + { + // remove any explicit overrides, returning whether that was an actual change + bool result = false; + foreach (var m in _members) + { + if (m.ExplicitOverride) + { + result = true; // someone was explicitly enabled + m.ExplicitOverride = false; + } + } + SelectPreferredGroup(); + return result; + } + + var members = _members; + if (!members.Contains(member)) + { + // not one of ours? + return false; + } + + if (!member.IsConnected) + { + // not allowed + return false; + } + + if (member.ExplicitOverride) + { + // already preferred; no change, but report as success + return true; + } + + // otherwise, deselect everyone else, and select this one + foreach (var m in members) + { + m.ExplicitOverride = ReferenceEquals(m, member); + } + SelectPreferredGroup(); + return true; + } + + public bool Remove(ConnectionGroupMember member) + { + while (true) + { + var arr = _members; + int index = -1; + for (int i = 0; i < arr.Length; i++) + { + if (ReferenceEquals(arr[i], member)) + { + index = i; + break; + } + } + + if (index == -1) return false; + var newArr = new ConnectionGroupMember[arr.Length - 1]; + if (index > 0) Array.Copy(arr, 0, newArr, 0, index); + if (index < newArr.Length) Array.Copy(arr, index + 1, newArr, index, newArr.Length - index); + if (Interlocked.CompareExchange(ref _members, newArr, arr) == arr) break; + } + + var muxer = member.ClearMultiplexer(); + RemoveEventHandlers(muxer); + OnConnectionChanged(GroupConnectionChangedEventArgs.ChangeType.Removed, member); + SelectPreferredGroup(); + muxer?.Dispose(); + return true; + } + + internal void OnHeartbeat() // for testing, to update latency etc + { + foreach (var member in _members) + { + member.Multiplexer.OnHeartbeat(); + } + } + + internal void OnInternalError(Exception exception, EndPoint? endpoint = null, ConnectionType connectionType = ConnectionType.None, string? origin = null) + { + var handler = _internalError; + if (handler is not null) + { + InternalErrorEventArgs args = new(handler, this, endpoint, connectionType, exception, origin); + ConnectionMultiplexer.CompleteAsWorker(args); + } + } +} diff --git a/src/StackExchange.Redis/MultiGroupOptions.cs b/src/StackExchange.Redis/MultiGroupOptions.cs new file mode 100644 index 0000000000..8f783a7376 --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupOptions.cs @@ -0,0 +1,49 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Threading; +using RESPite; + +namespace StackExchange.Redis; + +/// +/// Configuration options for controlling connections to multiple groups. +/// +[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +public sealed class MultiGroupOptions() +{ + private static MultiGroupOptions? _default; + private bool _frozen; + + /// + /// Default shared options. + /// + public static MultiGroupOptions Default => _default ??= CreateDefault(); + + private static MultiGroupOptions CreateDefault() + { + var options = new MultiGroupOptions(); + options.Freeze(); + return Interlocked.CompareExchange(ref _default, options, null) ?? options; + } + + /// + /// The health check to use for members of the group when no per-member health check is specified. + /// + public HealthCheck HealthCheck + { + get => field ?? HealthCheck.Default; + set => SetField(ref field, value); + } + + // ReSharper disable once RedundantAssignment + private void SetField(ref T field, T value, [CallerMemberName] string caller = "") + { + if (_frozen) Throw(caller); + field = value; + + static void Throw(string caller) => throw new InvalidOperationException($"{nameof(MultiGroupOptions)}.{caller} cannot be modified once the object is in use."); + } + + internal void Freeze() => _frozen = true; +} diff --git a/src/StackExchange.Redis/MultiGroupSubscriber.Tracking.cs b/src/StackExchange.Redis/MultiGroupSubscriber.Tracking.cs new file mode 100644 index 0000000000..30653e8620 --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupSubscriber.Tracking.cs @@ -0,0 +1,246 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupSubscriber +{ + // for the actual pub/sub tracking, we need to be more subtle; we need to maintain our *own* map of what is subscribed, + // and only forward messages from the currently live system. + public void Subscribe( + RedisChannel channel, + Action handler, + CommandFlags flags = CommandFlags.None) => parent.SubscribeToAll(new(channel, handler, flags, asyncState)); + + public Task SubscribeAsync( + RedisChannel channel, + Action handler, + CommandFlags flags = CommandFlags.None) => parent.SubscribeToAllAsync(new(channel, handler, flags, asyncState)); + + public ChannelMessageQueue Subscribe( + RedisChannel channel, + CommandFlags flags = CommandFlags.None) + { + var tuple = new MultiGroupMultiplexer.HandlerTuple(channel, flags, asyncState); + parent.SubscribeToAll(tuple); + return tuple.Queue!; + } + + public async Task SubscribeAsync( + RedisChannel channel, + CommandFlags flags = CommandFlags.None) + { + var tuple = new MultiGroupMultiplexer.HandlerTuple(channel, flags, asyncState); + await parent.SubscribeToAllAsync(tuple); + return tuple.Queue!; + } + + // to do this we'd need to track the *filtered* subscriber per node per subscriber per channel + public void Unsubscribe( + RedisChannel channel, + Action? handler = null, + CommandFlags flags = CommandFlags.None) => throw new NotSupportedException("Removing individual pub/sub handlers from multi-group subscribers is not currently supported"); + + public Task UnsubscribeAsync( + RedisChannel channel, + Action? handler = null, + CommandFlags flags = CommandFlags.None) => throw new NotSupportedException("Removing individual pub/sub handlers from multi-group subscribers is not currently supported"); + + public void UnsubscribeAll(CommandFlags flags = CommandFlags.None) => parent.UnsubscribeFromAll(flags, asyncState); + + public Task UnsubscribeAllAsync(CommandFlags flags = CommandFlags.None) => parent.UnsubscribeFromAllAsync(flags, asyncState); +} + +internal sealed partial class MultiGroupMultiplexer +{ + private static Action FilteredHandler(MultiGroupMultiplexer parent, ConnectionGroupMember active, Action handler) + { + // invoke a handler only if the active member is the one we expect + return (channel, value) => + { + if (ReferenceEquals(parent._active, active.Multiplexer)) + { + handler(channel, value); + } + }; + } + + private static void ForwardFilteredMessages( + MultiGroupMultiplexer parent, + ConnectionGroupMember active, + ChannelMessageQueue writeTo, + ChannelMessageQueue readFrom) + { + // Create an async worker; note we can't just use a handler-style callback, because the + // key point of ChannelMessageQueue is to preserve order, and our callback implementation explicitly + // does not guarantee anything about order. + _ = Task.Run(() => ForwardFilteredMessagesAsync(parent, active, writeTo, readFrom)); + static async Task ForwardFilteredMessagesAsync( + MultiGroupMultiplexer parent, + ConnectionGroupMember active, + ChannelMessageQueue writeTo, + ChannelMessageQueue readFrom) + { + try + { + while (await readFrom.WaitToReadAsync()) + { + while (readFrom.TryRead(out var message)) + { + if (ReferenceEquals(parent._active, active.Multiplexer)) + { + // Because of the switchover being imperfect, we can't guarantee exactly one writer, so + // we need to be synchronized; in reality, it will *almost never* be contended, so + // this isn't a bottleneck - so we will pay the price of a lock here, and keep the + // queue in single-writer mode. + writeTo.SynchronizedWrite(message.Channel, message.Message); + } + } + } + } + catch (Exception ex) + { + parent.OnInternalError(ex); + } + } + } + + internal readonly struct HandlerTuple + { + public readonly RedisChannel Channel; + public Action? Handler => _handlerOrQueue as Action; + public ChannelMessageQueue? Queue => _handlerOrQueue as ChannelMessageQueue; + public readonly CommandFlags Flags; + public readonly object? AsyncState; + + private readonly object _handlerOrQueue; + + public HandlerTuple(RedisChannel channel, Action handler, CommandFlags flags, object? asyncState) + { + Channel = channel; + _handlerOrQueue = handler; + Flags = flags; + AsyncState = asyncState; + } + + public HandlerTuple(RedisChannel channel, CommandFlags flags, object? asyncState) + { + Channel = channel; + // note: multi-writer because we can't rule out race conditions at switchover + _handlerOrQueue = new ChannelMessageQueue(channel, null); + Flags = flags; + AsyncState = asyncState; + } + } + + private List _handlers = new(); + + internal void SubscribeToAll(HandlerTuple tuple) + { + lock (_handlers) + { + _handlers.Add(tuple); + } + foreach (var member in _members) + { + var sub = member.Multiplexer.GetSubscriber(tuple.AsyncState); + if (tuple.Handler is not null) + { + sub.Subscribe(tuple.Channel, FilteredHandler(this, member, tuple.Handler), tuple.Flags); + } + else if (tuple.Queue is not null) + { + var from = sub.Subscribe(tuple.Channel, tuple.Flags); + ForwardFilteredMessages(this, member, tuple.Queue, from); + } + } + } + + internal async Task SubscribeToAllAsync(HandlerTuple tuple) + { + lock (_handlers) + { + _handlers.Add(tuple); + } + foreach (var member in _members) + { + var sub = member.Multiplexer.GetSubscriber(tuple.AsyncState); + if (tuple.Handler is not null) + { + await sub.SubscribeAsync(tuple.Channel, FilteredHandler(this, member, tuple.Handler), tuple.Flags); + } + else if (tuple.Queue is not null) + { + var from = await sub.SubscribeAsync(tuple.Channel, tuple.Flags); + ForwardFilteredMessages(this, member, tuple.Queue, from); + } + } + } + + private HandlerTuple[] LeaseHandlers(out int count) + { + HandlerTuple[] lease; + lock (_handlers) + { + count = _handlers.Count; + if (count == 0) return []; + lease = ArrayPool.Shared.Rent(count); + _handlers.CopyTo(lease, 0); + } + + return lease; + } + + private async Task AddPubSubHandlersAsync(ConnectionGroupMember member) + { + // when adding a connection to an established group, add any missing pub/sub handlers + var lease = LeaseHandlers(out var count); + object? asyncState = null; + ISubscriber? sub = null; // try to reuse when possible + for (int i = 0; i < count; i++) + { + var tuple = lease[i]; + if (sub is null || tuple.AsyncState != asyncState) + { + asyncState = tuple.AsyncState; + sub = member.Multiplexer.GetSubscriber(asyncState); + } + if (tuple.Handler is not null) + { + await sub.SubscribeAsync(tuple.Channel, FilteredHandler(this, member, tuple.Handler), tuple.Flags); + } + else if (tuple.Queue is not null) + { + var from = await sub.SubscribeAsync(tuple.Channel, tuple.Flags); + ForwardFilteredMessages(this, member, tuple.Queue, from); + } + } + ArrayPool.Shared.Return(lease); + } + + public void UnsubscribeFromAll(CommandFlags flags, object? asyncState) + { + lock (_handlers) + { + _handlers.Clear(); + } + foreach (var member in _members) + { + member.Multiplexer.GetSubscriber(asyncState).UnsubscribeAll(flags); + } + } + + public async Task UnsubscribeFromAllAsync(CommandFlags flags, object? asyncState) + { + lock (_handlers) + { + _handlers.Clear(); + } + foreach (var member in _members) + { + await member.Multiplexer.GetSubscriber(asyncState).UnsubscribeAllAsync(flags); + } + } +} diff --git a/src/StackExchange.Redis/MultiGroupSubscriber.cs b/src/StackExchange.Redis/MultiGroupSubscriber.cs new file mode 100644 index 0000000000..4b62d58905 --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupSubscriber.cs @@ -0,0 +1,41 @@ +using System; +using System.Net; +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupSubscriber(MultiGroupMultiplexer parent, object? asyncState) : ISubscriber +{ + // for a lot of things, we can defer through to the active implementation + private ISubscriber GetActiveSubscriber() => parent.Active.GetSubscriber(asyncState); + + public IConnectionMultiplexer Multiplexer => parent; + + public bool TryWait(Task task) => GetActiveSubscriber().TryWait(task); + + public void Wait(Task task) => GetActiveSubscriber().Wait(task); + + public T Wait(Task task) => GetActiveSubscriber().Wait(task); + + public void WaitAll(params Task[] tasks) => GetActiveSubscriber().WaitAll(tasks); + + public TimeSpan Ping(CommandFlags flags = CommandFlags.None) => GetActiveSubscriber().Ping(flags); + + public Task PingAsync(CommandFlags flags = CommandFlags.None) => GetActiveSubscriber().PingAsync(flags); + + public EndPoint? IdentifyEndpoint(RedisChannel channel, CommandFlags flags = CommandFlags.None) => + GetActiveSubscriber().IdentifyEndpoint(channel, flags); + + public Task IdentifyEndpointAsync(RedisChannel channel, CommandFlags flags = CommandFlags.None) => + GetActiveSubscriber().IdentifyEndpointAsync(channel, flags); + + public bool IsConnected(RedisChannel channel = default) => GetActiveSubscriber().IsConnected(); + + public long Publish(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) + => GetActiveSubscriber().Publish(channel, message, flags); + + public Task PublishAsync(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) + => GetActiveSubscriber().PublishAsync(channel, message, flags); + + public EndPoint? SubscribedEndpoint(RedisChannel channel) => GetActiveSubscriber().SubscribedEndpoint(channel); +} diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 7dc5c58110..852e623a2e 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,87 @@ -#nullable enable +#nullable enable +abstract StackExchange.Redis.HealthCheck.HealthCheckProbe.CheckHealthAsync(StackExchange.Redis.HealthCheck! healthCheck, StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! +abstract StackExchange.Redis.HealthCheck.HealthCheckProbePolicy.Evaluate(in StackExchange.Redis.HealthCheck.HealthCheckProbeContext context) -> StackExchange.Redis.HealthCheck.HealthCheckResult +abstract StackExchange.Redis.HealthCheck.KeyWriteHealthCheckProbe.CheckHealthAsync(StackExchange.Redis.HealthCheck! healthCheck, StackExchange.Redis.IDatabaseAsync! database, StackExchange.Redis.RedisKey key) -> System.Threading.Tasks.Task! +override StackExchange.Redis.HealthCheck.HealthCheckProbeContext.ToString() -> string! +override StackExchange.Redis.HealthCheck.KeyWriteHealthCheckProbe.CheckHealthAsync(StackExchange.Redis.HealthCheck! healthCheck, StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! +StackExchange.Redis.HealthCheck +StackExchange.Redis.HealthCheck.CheckHealthAsync(StackExchange.Redis.IConnectionMultiplexer! multiplexer) -> System.Threading.Tasks.Task! +StackExchange.Redis.HealthCheck.CheckHealthAsync(StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! +StackExchange.Redis.HealthCheck.Clone() -> StackExchange.Redis.HealthCheck! +StackExchange.Redis.HealthCheck.HealthCheck() -> void +StackExchange.Redis.HealthCheck.HealthCheckProbe +StackExchange.Redis.HealthCheck.HealthCheckProbe.HealthCheckProbe() -> void +StackExchange.Redis.HealthCheck.HealthCheckProbeContext +StackExchange.Redis.HealthCheck.HealthCheckProbeContext.Failure.get -> int +StackExchange.Redis.HealthCheck.HealthCheckProbeContext.HealthCheckProbeContext() -> void +StackExchange.Redis.HealthCheck.HealthCheckProbeContext.HealthCheckProbeContext(StackExchange.Redis.HealthCheck.HealthCheckResult result, int success, int failure, int remaining, System.TimeSpan probeInterval) -> void +StackExchange.Redis.HealthCheck.HealthCheckProbeContext.ProbeInterval.get -> System.TimeSpan +StackExchange.Redis.HealthCheck.HealthCheckProbeContext.Remaining.get -> int +StackExchange.Redis.HealthCheck.HealthCheckProbeContext.Result.get -> StackExchange.Redis.HealthCheck.HealthCheckResult +StackExchange.Redis.HealthCheck.HealthCheckProbeContext.Success.get -> int +StackExchange.Redis.HealthCheck.HealthCheckProbePolicy +StackExchange.Redis.HealthCheck.HealthCheckProbePolicy.HealthCheckProbePolicy() -> void +StackExchange.Redis.HealthCheck.HealthCheckResult +StackExchange.Redis.HealthCheck.HealthCheckResult.Healthy = 1 -> StackExchange.Redis.HealthCheck.HealthCheckResult +StackExchange.Redis.HealthCheck.HealthCheckResult.Inconclusive = 0 -> StackExchange.Redis.HealthCheck.HealthCheckResult +StackExchange.Redis.HealthCheck.HealthCheckResult.Unhealthy = 2 -> StackExchange.Redis.HealthCheck.HealthCheckResult +StackExchange.Redis.HealthCheck.Interval.get -> System.TimeSpan +StackExchange.Redis.HealthCheck.Interval.set -> void +StackExchange.Redis.HealthCheck.KeyWriteHealthCheckProbe +StackExchange.Redis.HealthCheck.KeyWriteHealthCheckProbe.KeyWriteHealthCheckProbe() -> void +StackExchange.Redis.HealthCheck.Probe.get -> StackExchange.Redis.HealthCheck.HealthCheckProbe! +StackExchange.Redis.HealthCheck.Probe.set -> void +StackExchange.Redis.HealthCheck.ProbeCount.get -> int +StackExchange.Redis.HealthCheck.ProbeCount.set -> void +StackExchange.Redis.HealthCheck.ProbeInterval.get -> System.TimeSpan +StackExchange.Redis.HealthCheck.ProbeInterval.set -> void +StackExchange.Redis.HealthCheck.ProbePolicy.get -> StackExchange.Redis.HealthCheck.HealthCheckProbePolicy! +StackExchange.Redis.HealthCheck.ProbePolicy.set -> void +StackExchange.Redis.HealthCheck.ProbeTimeout.get -> System.TimeSpan +StackExchange.Redis.HealthCheck.ProbeTimeout.set -> void +StackExchange.Redis.IServer.InventKey(StackExchange.Redis.RedisKey prefix = default(StackExchange.Redis.RedisKey)) -> StackExchange.Redis.RedisKey +static StackExchange.Redis.HealthCheck.Default.get -> StackExchange.Redis.HealthCheck! +static StackExchange.Redis.HealthCheck.HealthCheckProbe.HealthyTask.get -> System.Threading.Tasks.Task! +static StackExchange.Redis.HealthCheck.HealthCheckProbe.InconclusiveTask.get -> System.Threading.Tasks.Task! +static StackExchange.Redis.HealthCheck.HealthCheckProbe.IsConnected.get -> StackExchange.Redis.HealthCheck.HealthCheckProbe! +static StackExchange.Redis.HealthCheck.HealthCheckProbe.Ping.get -> StackExchange.Redis.HealthCheck.HealthCheckProbe! +static StackExchange.Redis.HealthCheck.HealthCheckProbe.StringSet.get -> StackExchange.Redis.HealthCheck.HealthCheckProbe! +static StackExchange.Redis.HealthCheck.HealthCheckProbe.UnhealthyTask.get -> System.Threading.Tasks.Task! +static StackExchange.Redis.HealthCheck.HealthCheckProbePolicy.AllSuccess.get -> StackExchange.Redis.HealthCheck.HealthCheckProbePolicy! +static StackExchange.Redis.HealthCheck.HealthCheckProbePolicy.AnySuccess.get -> StackExchange.Redis.HealthCheck.HealthCheckProbePolicy! +static StackExchange.Redis.HealthCheck.HealthCheckProbePolicy.MajoritySuccess.get -> StackExchange.Redis.HealthCheck.HealthCheckProbePolicy! +[SER007]override StackExchange.Redis.ConnectionGroupMember.ToString() -> string! +[SER007]StackExchange.Redis.ConnectionGroupMember +[SER007]StackExchange.Redis.ConnectionGroupMember.ConnectionGroupMember(StackExchange.Redis.ConfigurationOptions! configuration, string! name = "") -> void +[SER007]StackExchange.Redis.ConnectionGroupMember.ConnectionGroupMember(string! configuration, string! name = "") -> void +[SER007]StackExchange.Redis.ConnectionGroupMember.IsConnected.get -> bool +[SER007]StackExchange.Redis.ConnectionGroupMember.Latency.get -> System.TimeSpan +[SER007]StackExchange.Redis.ConnectionGroupMember.Name.get -> string! +[SER007]StackExchange.Redis.ConnectionGroupMember.Weight.get -> double +[SER007]StackExchange.Redis.ConnectionGroupMember.Weight.set -> void +[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs +[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType +[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType.ActiveChanged = 5 -> StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType +[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType.Added = 1 -> StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType +[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType.Disconnected = 3 -> StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType +[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType.Reconnected = 4 -> StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType +[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType.Removed = 2 -> StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType +[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType.Unknown = 0 -> StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType +[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs.Group.get -> StackExchange.Redis.ConnectionGroupMember! +[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs.GroupConnectionChangedEventArgs(StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType type, StackExchange.Redis.ConnectionGroupMember! group, StackExchange.Redis.ConnectionGroupMember? previousGroup = null) -> void +[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs.PreviousGroup.get -> StackExchange.Redis.ConnectionGroupMember? +[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs.Type.get -> StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType +[SER007]StackExchange.Redis.IConnectionGroup +[SER007]StackExchange.Redis.IConnectionGroup.ActiveMember.get -> StackExchange.Redis.ConnectionGroupMember? +[SER007]StackExchange.Redis.IConnectionGroup.AddAsync(StackExchange.Redis.ConnectionGroupMember! member, System.IO.TextWriter? log = null) -> System.Threading.Tasks.Task! +[SER007]StackExchange.Redis.IConnectionGroup.ConnectionChanged -> System.EventHandler? +[SER007]StackExchange.Redis.IConnectionGroup.GetMembers() -> System.ReadOnlySpan +[SER007]StackExchange.Redis.IConnectionGroup.Remove(StackExchange.Redis.ConnectionGroupMember! member) -> bool +[SER007]StackExchange.Redis.IConnectionGroup.TryFailoverTo(StackExchange.Redis.ConnectionGroupMember? member) -> bool +[SER007]StackExchange.Redis.MultiGroupOptions +[SER007]StackExchange.Redis.MultiGroupOptions.HealthCheck.get -> StackExchange.Redis.HealthCheck! +[SER007]StackExchange.Redis.MultiGroupOptions.HealthCheck.set -> void +[SER007]StackExchange.Redis.MultiGroupOptions.MultiGroupOptions() -> void +[SER007]static StackExchange.Redis.ConnectionMultiplexer.ConnectGroupAsync(StackExchange.Redis.ConnectionGroupMember! member0, StackExchange.Redis.ConnectionGroupMember! member1, StackExchange.Redis.MultiGroupOptions? options = null, System.IO.TextWriter? log = null) -> System.Threading.Tasks.Task! +[SER007]static StackExchange.Redis.ConnectionMultiplexer.ConnectGroupAsync(StackExchange.Redis.ConnectionGroupMember![]! members, StackExchange.Redis.MultiGroupOptions? options = null, System.IO.TextWriter? log = null) -> System.Threading.Tasks.Task! +[SER007]static StackExchange.Redis.MultiGroupOptions.Default.get -> StackExchange.Redis.MultiGroupOptions! diff --git a/src/StackExchange.Redis/RedisServer.cs b/src/StackExchange.Redis/RedisServer.cs index ef58d3bbfe..aa8f3d6ff8 100644 --- a/src/StackExchange.Redis/RedisServer.cs +++ b/src/StackExchange.Redis/RedisServer.cs @@ -53,6 +53,18 @@ public bool AllowReplicaWrites public Version Version => server.Version; + public RedisKey InventKey(RedisKey prefix = default) + { + var guid = Guid.NewGuid(); + if (server.ServerType is ServerType.Cluster) + { + var hashTag = multiplexer.ServerSelectionStrategy.GetHashTag(server); + if (string.IsNullOrEmpty(hashTag)) return RedisKey.Null; + return prefix.Append($"{guid}:{{{hashTag}}}"); + } + return prefix.Append(guid.ToString()); + } + public void ClientKill(EndPoint endpoint, CommandFlags flags = CommandFlags.None) { var msg = Message.Create(-1, flags, RedisCommand.CLIENT, RedisLiterals.KILL, (RedisValue)Format.ToString(endpoint)); diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index 55b502c400..4e6271283b 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -3152,21 +3152,15 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes } } - private sealed class TracerProcessor : ResultProcessor + private sealed class TracerProcessor(bool establishConnection) : ResultProcessor { - private readonly bool establishConnection; - - public TracerProcessor(bool establishConnection) - { - this.establishConnection = establishConnection; - } - public override bool SetResult(PhysicalConnection connection, Message message, ref RespReader reader) { reader.MovePastBof(); bool isError = reader.IsError; var copy = reader; + connection.BridgeCouldBeNull?.ServerEndPoint?.SetLatency(message.CreatedDateTime); connection.BridgeCouldBeNull?.Multiplexer.OnInfoMessage($"got '{reader.Prefix}' for '{message.CommandAndKey}' on '{connection}'"); var final = base.SetResult(connection, message, ref reader); diff --git a/src/StackExchange.Redis/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs index eca06f30d6..c6abbd7337 100644 --- a/src/StackExchange.Redis/ServerEndPoint.cs +++ b/src/StackExchange.Redis/ServerEndPoint.cs @@ -1144,6 +1144,20 @@ internal bool HasPendingCallerFacingItems() return subscription?.HasPendingCallerFacingItems() ?? false; } + public void SetLatency(DateTime startTime) + { + try + { + LatencyTicks = ConnectionGroupMember.ToLatencyTicks(DateTime.UtcNow - startTime); + } + catch (Exception ex) + { + Debug.WriteLine(ex.Message); + } + } + + internal uint LatencyTicks { get; private set; } = uint.MaxValue; + private ProductVariant _productVariant = ProductVariant.Redis; private string _productVersion = ""; diff --git a/src/StackExchange.Redis/ServerSelectionStrategy.HashTags.cs b/src/StackExchange.Redis/ServerSelectionStrategy.HashTags.cs new file mode 100644 index 0000000000..4cbd4538c9 --- /dev/null +++ b/src/StackExchange.Redis/ServerSelectionStrategy.HashTags.cs @@ -0,0 +1,86 @@ +using System; +using System.Diagnostics; +using System.Text; + +namespace StackExchange.Redis; + +internal sealed partial class ServerSelectionStrategy +{ + // pre-computed hash-tags for each slot + private static class HashTags + { + private static readonly string[] Cache = Populate(); + public static ReadOnlySpan Tags => Cache; + public static string Get(int slot) => Cache[slot]; + + private static string[] Populate() + { + // Via testing, we know that 3 characters is sufficient to populate all slots + // using a total of 48643 operations - this is acceptable (same order-of-magnitude as the slot count). + var slots = new string?[TotalSlots]; + + // using an alphabet of the visible ASCII characters, excluding { and } (used to denote hash-tags) + ReadOnlySpan alphabet = "!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz|~"u8; + Debug.WriteLine($"Alphabet: '{Encoding.ASCII.GetString(alphabet)}', {alphabet.Length} chars"); + + Span threeChars = stackalloc byte[3]; + Span twoChars = threeChars.Slice(0, 2); + Span oneChar = threeChars.Slice(0, 1); + + int operations = 0; + int remaining = slots.Length; + + bool Test(ReadOnlySpan span) + { + var slot = GetClusterSlot(span); + operations++; + if (slots[slot] is { } existing) + { + // prefer smaller tags (but doesn't change the outcome) + if (span.Length < existing.Length) + { + slots[slot] = Encoding.ASCII.GetString(span); + } + } + else + { + // new value for this slot + slots[slot] = Encoding.ASCII.GetString(span); + return --remaining == 0; + } + + return false; + } + for (int i = 0; i < alphabet.Length; i++) + { + oneChar[0] = alphabet[i]; + + // Test single character keys + if (i == 0) Test(oneChar); + + for (int j = 0; j < alphabet.Length; j++) + { + twoChars[1] = alphabet[j]; + + // Test two characters keys + if (i == 0) Test(twoChars); + + for (int k = 0; k < alphabet.Length; k++) + { + threeChars[2] = alphabet[k]; + + // Test three characters - we know this is the only possible exit location + if (Test(threeChars)) + { + Debug.WriteLine($"Populated all hash-tag slots in {operations} operations"); + return slots!; + } + } + } + } + + throw new InvalidOperationException( + $"Failed to populate hash-tag cache after {operations} operations, {remaining} slots remaining"); + } + } +} diff --git a/src/StackExchange.Redis/ServerSelectionStrategy.cs b/src/StackExchange.Redis/ServerSelectionStrategy.cs index 5025b732de..27358e8c74 100644 --- a/src/StackExchange.Redis/ServerSelectionStrategy.cs +++ b/src/StackExchange.Redis/ServerSelectionStrategy.cs @@ -8,7 +8,7 @@ namespace StackExchange.Redis { - internal sealed class ServerSelectionStrategy + internal sealed partial class ServerSelectionStrategy { public const int NoSlot = -1, MultipleSlots = -2; private const int RedisClusterSlotCount = 16384; @@ -418,5 +418,29 @@ internal bool CanServeSlot(ServerEndPoint server, int slot) } return false; } + + /// + /// Gets a string that can be used as a hash-tag to reference a specific slot. + /// + internal string GetHashTag(ServerEndPoint endpoint) + { + if (map is { } arr) + { + // inefficient way of finding a slot for a given endpoint, but: it'll work + for (int i = 0; i < arr.Length; i++) + { + if (arr[i] == endpoint) + { + return HashTags.Get(i); + } + } + } + return ""; + } + + /// + /// Gets a string that can be used as a hash-tag to reference a specific slot. + /// + internal static string GetHashTag(int slot) => slot < 0 ? "" : HashTags.Get(slot); } } diff --git a/src/StackExchange.Redis/StackExchange.Redis.csproj b/src/StackExchange.Redis/StackExchange.Redis.csproj index d3dd60aae3..addda59b7f 100644 --- a/src/StackExchange.Redis/StackExchange.Redis.csproj +++ b/src/StackExchange.Redis/StackExchange.Redis.csproj @@ -58,4 +58,13 @@ + + + + MultiGroupDatabase.cs + + + HealthCheck.cs + + \ No newline at end of file diff --git a/src/StackExchange.Redis/TaskExtensions.cs b/src/StackExchange.Redis/TaskExtensions.cs index 989c0861e7..344dff75c4 100644 --- a/src/StackExchange.Redis/TaskExtensions.cs +++ b/src/StackExchange.Redis/TaskExtensions.cs @@ -26,6 +26,11 @@ internal static Task ObserveErrors(this Task task) } #if !NET + extension(Task task) + { + public bool IsCompletedSuccessfully => task.Status is TaskStatus.RanToCompletion; + } + // suboptimal polyfill version of the .NET 6+ API, but reasonable for light use internal static Task WaitAsync(this Task task, CancellationToken cancellationToken) { diff --git a/tests/StackExchange.Redis.Tests/ActiveActiveIntegrationTests.cs b/tests/StackExchange.Redis.Tests/ActiveActiveIntegrationTests.cs new file mode 100644 index 0000000000..fedf41bd33 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ActiveActiveIntegrationTests.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading.Tasks; +using StackExchange.Redis.Tests.Helpers; +using Xunit; + +namespace StackExchange.Redis.Tests; + +public class ActiveActiveIntegrationTests(ITestOutputHelper output) +{ + [Fact] + public async Task ProductionActiveActive() + { + var config = TestConfig.Current; + var eps = config.ActiveActiveEndpoints; + Assert.SkipUnless(eps is { Length: > 0 }, "no active:active endpoints"); + + var writer = new TextWriterOutputHelper(output); + ConnectionGroupMember[] members = Array.ConvertAll(eps, x => new ConnectionGroupMember(x)); + var muxer = await ConnectionMultiplexer.ConnectGroupAsync(members, log: writer); + + Assert.True(muxer.IsConnected); + var db = muxer.GetDatabase(); + Assert.True(db.IsConnected(default)); + await db.PingAsync(); + + Task last = Task.CompletedTask; + var ttl = TimeSpan.FromMinutes(5); + for (int i = 0; i < 100; i++) + { + RedisKey key = Guid.NewGuid().ToString(); + _ = db.StringSetAsync(key, i, ttl, flags: CommandFlags.FireAndForget); + last.RedisFireAndForget(); // in case of fault + last = db.StringGetAsync(key); // observe the last + } + + await last; + + foreach (var member in muxer.GetMembers()) + { + output?.WriteLine($"{member.Name}: {member.Latency.TotalMilliseconds}us"); + } + output?.WriteLine($"Active: {muxer.ActiveMember?.Name}"); + } +} diff --git a/tests/StackExchange.Redis.Tests/HashTagUnitTests.cs b/tests/StackExchange.Redis.Tests/HashTagUnitTests.cs new file mode 100644 index 0000000000..91b63d9804 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/HashTagUnitTests.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Xunit; + +namespace StackExchange.Redis.Tests; + +public class HashTagUnitTests +{ + [Fact] + public void TestHashTagCoverage() + { + HashSet uniques = []; + Assert.Equal("", ServerSelectionStrategy.GetHashTag(ServerSelectionStrategy.NoSlot)); + Assert.Equal("", ServerSelectionStrategy.GetHashTag(ServerSelectionStrategy.MultipleSlots)); + Span buffer = stackalloc byte[3]; + for (int i = 0; i < ServerSelectionStrategy.TotalSlots; i++) + { + var tag = ServerSelectionStrategy.GetHashTag(i); + Assert.False(string.IsNullOrEmpty(tag)); + Assert.True(uniques.Add(tag)); + + var len = Encoding.ASCII.GetBytes(tag, buffer); + var slot = ServerSelectionStrategy.GetClusterSlot(buffer.Slice(0, len)); + Assert.Equal(i, slot); + } + Assert.Equal(ServerSelectionStrategy.TotalSlots, uniques.Count); + } +} diff --git a/tests/StackExchange.Redis.Tests/HealthCheckPolicyUnitTests.cs b/tests/StackExchange.Redis.Tests/HealthCheckPolicyUnitTests.cs new file mode 100644 index 0000000000..b2dd97b711 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/HealthCheckPolicyUnitTests.cs @@ -0,0 +1,108 @@ +using System; +using Xunit; +using static StackExchange.Redis.HealthCheck; + +namespace StackExchange.Redis.Tests; + +public class HealthCheckPolicyUnitTests +{ + [Theory] + [InlineData(0, 0, 5, HealthCheckResult.Inconclusive)] // No results yet + [InlineData(1, 0, 0, HealthCheckResult.Healthy)] // One success, no more probes + [InlineData(0, 1, 0, HealthCheckResult.Unhealthy)] // One failure, no more probes + [InlineData(2, 1, 0, HealthCheckResult.Healthy)] // Mixed results, success wins + [InlineData(1, 2, 0, HealthCheckResult.Healthy)] // Mixed results, success wins + [InlineData(5, 0, 0, HealthCheckResult.Healthy)] // All successes + [InlineData(0, 5, 0, HealthCheckResult.Unhealthy)] // All failures + [InlineData(1, 0, 2, HealthCheckResult.Healthy)] // Early success + [InlineData(0, 1, 2, HealthCheckResult.Inconclusive)] // Early failure but more probes remain + public void AnySuccess_EvaluatesCorrectly(int success, int failure, int remaining, HealthCheckResult expected) + { + var policy = HealthCheckProbePolicy.AnySuccess; + var context = new HealthCheckProbeContext(HealthCheckResult.Inconclusive, success, failure, remaining, TimeSpan.Zero); + + var result = policy.Evaluate(context); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(0, 0, 5, HealthCheckResult.Inconclusive)] // No results yet + [InlineData(1, 0, 0, HealthCheckResult.Healthy)] // One success, no more probes + [InlineData(0, 1, 0, HealthCheckResult.Unhealthy)] // One failure, no more probes + [InlineData(2, 1, 0, HealthCheckResult.Unhealthy)] // Mixed results, one failure is enough + [InlineData(1, 2, 0, HealthCheckResult.Unhealthy)] // Mixed results, one failure is enough + [InlineData(5, 0, 0, HealthCheckResult.Healthy)] // All successes + [InlineData(0, 5, 0, HealthCheckResult.Unhealthy)] // All failures + [InlineData(1, 0, 2, HealthCheckResult.Inconclusive)] // Success but more probes remain + [InlineData(0, 1, 2, HealthCheckResult.Unhealthy)] // Early failure + [InlineData(4, 0, 1, HealthCheckResult.Inconclusive)] // Multiple successes but still waiting + public void AllSuccess_EvaluatesCorrectly(int success, int failure, int remaining, HealthCheckResult expected) + { + var policy = HealthCheckProbePolicy.AllSuccess; + var context = new HealthCheckProbeContext(HealthCheckResult.Inconclusive, success, failure, remaining, TimeSpan.Zero); + + var result = policy.Evaluate(context); + + Assert.Equal(expected, result); + } + + [Theory] + // Total 5 probes: need 3 for majority + [InlineData(0, 0, 5, HealthCheckResult.Inconclusive)] // No results yet + [InlineData(3, 0, 2, HealthCheckResult.Healthy)] // Reached majority (3/5) + [InlineData(2, 0, 3, HealthCheckResult.Inconclusive)] // Not yet majority + [InlineData(0, 3, 2, HealthCheckResult.Unhealthy)] // Majority impossible (3 failures) + [InlineData(2, 2, 1, HealthCheckResult.Inconclusive)] // Tied, one more probe + [InlineData(3, 2, 0, HealthCheckResult.Healthy)] // Majority achieved (3/5) + [InlineData(2, 3, 0, HealthCheckResult.Unhealthy)] // Majority failed (3/5) + [InlineData(5, 0, 0, HealthCheckResult.Healthy)] // All successes + [InlineData(0, 5, 0, HealthCheckResult.Unhealthy)] // All failures + + // Total 3 probes: need 2 for majority + [InlineData(0, 0, 3, HealthCheckResult.Inconclusive)] // No results yet (3 total) + [InlineData(2, 0, 1, HealthCheckResult.Healthy)] // Reached majority (2/3) + [InlineData(1, 0, 2, HealthCheckResult.Inconclusive)] // Not yet majority (3 total) + [InlineData(0, 2, 1, HealthCheckResult.Unhealthy)] // Majority impossible (2 failures of 3) + [InlineData(2, 1, 0, HealthCheckResult.Healthy)] // Majority achieved (2/3) + [InlineData(1, 2, 0, HealthCheckResult.Unhealthy)] // Majority failed (2/3) + + // Total 1 probe: need 1 for majority + [InlineData(0, 0, 1, HealthCheckResult.Inconclusive)] // No results yet (1 total) + [InlineData(1, 0, 0, HealthCheckResult.Healthy)] // Majority achieved (1/1) + [InlineData(0, 1, 0, HealthCheckResult.Unhealthy)] // Majority failed (1/1) + + // Total 6 probes: need 4 for majority + [InlineData(4, 0, 2, HealthCheckResult.Healthy)] // Reached majority (4/6) + [InlineData(3, 0, 3, HealthCheckResult.Inconclusive)] // Not yet majority (6 total) + [InlineData(0, 4, 2, HealthCheckResult.Unhealthy)] // Majority impossible (4 failures) + [InlineData(3, 3, 0, HealthCheckResult.Inconclusive)] // Tied, neither side has majority (3/6 is not >=4) + public void MajoritySuccess_EvaluatesCorrectly(int success, int failure, int remaining, HealthCheckResult expected) + { + var policy = HealthCheckProbePolicy.MajoritySuccess; + var context = new HealthCheckProbeContext(HealthCheckResult.Inconclusive, success, failure, remaining, TimeSpan.Zero); + + var result = policy.Evaluate(context); + + Assert.Equal(expected, result); + } + + [Fact] + public void Policies_AreSingletons() + { + var any1 = HealthCheckProbePolicy.AnySuccess; + var any2 = HealthCheckProbePolicy.AnySuccess; + Assert.NotNull(any1); + Assert.Same(any1, any2); + + var all1 = HealthCheckProbePolicy.AllSuccess; + var all2 = HealthCheckProbePolicy.AllSuccess; + Assert.NotNull(all1); + Assert.Same(all1, all2); + + var maj1 = HealthCheckProbePolicy.MajoritySuccess; + var maj2 = HealthCheckProbePolicy.MajoritySuccess; + Assert.NotNull(maj1); + Assert.Same(maj1, maj2); + } +} diff --git a/tests/StackExchange.Redis.Tests/Helpers/TestConfig.cs b/tests/StackExchange.Redis.Tests/Helpers/TestConfig.cs index c0194d5a6a..59520eab48 100644 --- a/tests/StackExchange.Redis.Tests/Helpers/TestConfig.cs +++ b/tests/StackExchange.Redis.Tests/Helpers/TestConfig.cs @@ -130,5 +130,6 @@ public class Config public int ProxyPort { get; set; } = 7015; public string ProxyServerAndPort => ProxyServer + ":" + ProxyPort.ToString(); + public string[] ActiveActiveEndpoints { get; set; } = []; } } diff --git a/tests/StackExchange.Redis.Tests/InProcessTestServer.cs b/tests/StackExchange.Redis.Tests/InProcessTestServer.cs index 7dcc5f7c9d..51faaa7fb7 100644 --- a/tests/StackExchange.Redis.Tests/InProcessTestServer.cs +++ b/tests/StackExchange.Redis.Tests/InProcessTestServer.cs @@ -403,4 +403,13 @@ protected override void Dispose(bool disposing) if (disposing) _server.Dispose(); } */ + public void SetLatency(TimeSpan latency) => _latency = latency; + + private TimeSpan _latency = TimeSpan.Zero; + + protected override ValueTask ClientPauseAsync(RedisClient client) + { + var latency = _latency; + return latency <= TimeSpan.Zero ? base.ClientPauseAsync(client) : new(Task.Delay(latency)); + } } diff --git a/tests/StackExchange.Redis.Tests/MultiGroupTests/BasicMultiGroupTests.cs b/tests/StackExchange.Redis.Tests/MultiGroupTests/BasicMultiGroupTests.cs new file mode 100644 index 0000000000..d72e3a34f1 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/MultiGroupTests/BasicMultiGroupTests.cs @@ -0,0 +1,385 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Threading.Tasks; +using StackExchange.Redis.Tests.Helpers; +using Xunit; + +namespace StackExchange.Redis.Tests.MultiGroupTests; + +[RunPerProtocol] +public class BasicMultiGroupTests(ITestOutputHelper log) +{ + private sealed class Capture(ITestOutputHelper log) + { + private readonly List _seen = []; + + public void Seen(string source, RedisChannel channel, RedisValue value) + { + string message = $"[{source}] {channel}: {value}"; + lock (_seen) + { + _seen.Add(message); + } + } + + public void Reset() + { + lock (_seen) + { + _seen.Clear(); + } + } + + public void AssertTakeAny(string value) + { + lock (_seen) + { + Assert.True(_seen.Remove(value), $"Expected to find '{value}', but did not"); + } + } + + public void AssertTakeFirst(string value) + { + lock (_seen) + { + Assert.NotEmpty(_seen); + Assert.Equal(value, _seen[0]); + _seen.RemoveAt(0); // not concerned by perf here + } + } + + public async ValueTask AwaitAsync(ISubscriber sub, int expected) + { + for (int i = 0; i < 10; i++) + { + lock (_seen) + { + if (_seen.Count >= expected) + { + Assert.Equal(expected, _seen.Count); + log.WriteLine("Messages:"); + foreach (var item in _seen) + { + log.WriteLine(item); + } + return; + } + } + log.WriteLine($"Waiting for {expected} messages, got {i}, pausing..."); + await Task.Delay(10, TestContext.Current.CancellationToken); + await sub.PingAsync(); + } + + int actual; + lock (_seen) + { + actual = _seen.Count; + } + throw new TimeoutException($"Timed out waiting for {expected} messages, got {actual}"); + } + + public Task WriteSeen(string source, ChannelMessageQueue queue) => + Task.Run(async () => + { + await foreach (var msg in queue) + { + Seen(source, msg.Channel, msg.Message); + } + }); + } + protected TextWriter Log { get; } = new TextWriterOutputHelper(log); + + public enum InbuiltProbe + { + IsConnected, + Ping, + StringSet, + } + + [Theory] + [InlineData(InbuiltProbe.IsConnected, ServerType.Standalone)] + [InlineData(InbuiltProbe.Ping, ServerType.Standalone)] + [InlineData(InbuiltProbe.StringSet, ServerType.Standalone)] + [InlineData(InbuiltProbe.IsConnected, ServerType.Cluster)] + [InlineData(InbuiltProbe.Ping, ServerType.Cluster)] + [InlineData(InbuiltProbe.StringSet, ServerType.Cluster)] + public async Task SelectByWeight(InbuiltProbe probe, ServerType serverType) + { + var healthCheck = new HealthCheck + { + Probe = probe switch + { + InbuiltProbe.IsConnected => HealthCheck.HealthCheckProbe.IsConnected, + InbuiltProbe.Ping => HealthCheck.HealthCheckProbe.Ping, + InbuiltProbe.StringSet => HealthCheck.HealthCheckProbe.StringSet, + _ => throw new ArgumentOutOfRangeException(nameof(probe)), + }, + }; + + EndPoint germany = new DnsEndPoint("germany", 6379); + EndPoint canada = new DnsEndPoint("canada", 6379); + EndPoint tokyo = new DnsEndPoint("tokyo", 6379); + + using var server0 = new InProcessTestServer(log, endpoint: germany) { ServerType = serverType }; + using var server1 = new InProcessTestServer(log, endpoint: canada) { ServerType = serverType }; + using var server2 = new InProcessTestServer(log, endpoint: tokyo) { ServerType = serverType }; + + ConnectionGroupMember[] members = [ + new(server0.GetClientConfig()) { Weight = 2 }, + new(server1.GetClientConfig()) { Weight = 9 }, + new(server2.GetClientConfig()) { Weight = 3 }, + ]; + var options = new MultiGroupOptions { HealthCheck = healthCheck }; + await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members, options); + Assert.True(conn.IsConnected); + var typed = Assert.IsType(conn); + + // (R.4.1) If multiple member databases are configured, then I want to failover to the one with the highest weight. + var db = conn.GetDatabase(); + var ep = await db.IdentifyEndpointAsync(); + Assert.Equal(canada, ep); + + // change weight and update + members[1].Weight = 1; + typed.SelectPreferredGroup(); + ep = await db.IdentifyEndpointAsync(); + Assert.Equal(tokyo, ep); + + WriteLatency(conn); + } + + private void WriteLatency(IConnectionGroup conn) + { + var typed = Assert.IsType(conn); + foreach (var member in conn.GetMembers()) + { + log.WriteLine($"{member.Name}: {member.Latency.TotalMilliseconds}us"); + } + log.WriteLine($"Active: {typed.Active}"); + } + + [Fact] + public async Task SelectByLatency() + { + EndPoint germany = new DnsEndPoint("germany", 6379); + EndPoint canada = new DnsEndPoint("canada", 6379); + EndPoint tokyo = new DnsEndPoint("tokyo", 6379); + + using var server0 = new InProcessTestServer(log, endpoint: germany); + using var server1 = new InProcessTestServer(log, endpoint: canada); + using var server2 = new InProcessTestServer(log, endpoint: tokyo); + + ConnectionGroupMember[] members = [ + new(server0.GetClientConfig()), + new(server1.GetClientConfig()), + new(server2.GetClientConfig()), + ]; + await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members); + conn.ConnectionChanged += (_, args) => log.WriteLine($"Connection changed: {args.Type}, from {args.PreviousGroup?.Name ?? "(nil)"} to {args.Group.Name}"); + + Assert.True(conn.IsConnected); + server0.SetLatency(TimeSpan.FromMilliseconds(10)); + server1.SetLatency(TimeSpan.Zero); + server2.SetLatency(TimeSpan.FromMilliseconds(15)); + var typed = Assert.IsType(conn); + typed.OnHeartbeat(); // update latencies + await Task.Delay(100); // allow time to settle + typed.SelectPreferredGroup(); + WriteLatency(typed); + + // (R.4.1) If multiple member databases are configured, then I want to failover to the one with the highest weight. + var db = conn.GetDatabase(); + var ep = await db.IdentifyEndpointAsync(); + Assert.Equal(canada, ep); + + // change latency and update + server0.SetLatency(TimeSpan.FromMilliseconds(10)); + server1.SetLatency(TimeSpan.FromMilliseconds(10)); + server2.SetLatency(TimeSpan.Zero); + typed.OnHeartbeat(); // update latencies + await Task.Delay(100); // allow time to settle + typed.SelectPreferredGroup(); + ep = await db.IdentifyEndpointAsync(); + WriteLatency(typed); + Assert.Equal(tokyo, ep); + } + + [Fact] + public async Task PubSubRouted() + { + EndPoint germany = new DnsEndPoint("germany", 6379); + EndPoint canada = new DnsEndPoint("canada", 6379); + EndPoint tokyo = new DnsEndPoint("tokyo", 6379); + + using var server0 = new InProcessTestServer(log, endpoint: germany); + Assert.SkipUnless(server0.GetClientConfig().CommandMap.IsAvailable(RedisCommand.PUBLISH), "PUBLISH is not available"); + using var server1 = new InProcessTestServer(log, endpoint: canada); + using var server2 = new InProcessTestServer(log, endpoint: tokyo); + + Capture capture = new(log); + + RedisChannel channel = RedisChannel.Literal("chan"); + var pub0 = (await server0.ConnectAsync()).GetSubscriber(); + await pub0.SubscribeAsync(channel, (x, y) => capture.Seen(nameof(pub0), x, y)); + var pub1 = (await server1.ConnectAsync()).GetSubscriber(); + await pub1.SubscribeAsync(channel, (x, y) => capture.Seen(nameof(pub1), x, y)); + var pub2 = (await server2.ConnectAsync()).GetSubscriber(); + await pub2.SubscribeAsync(channel, (x, y) => capture.Seen(nameof(pub2), x, y)); + + ConnectionGroupMember[] members = [ + new(server0.GetClientConfig()) { Weight = 2 }, + new(server1.GetClientConfig()) { Weight = 9 }, + new(server2.GetClientConfig()) { Weight = 3 }, + ]; + await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members); + Assert.True(conn.IsConnected); + var typed = Assert.IsType(conn); + var multi = conn.GetSubscriber(); + await multi.SubscribeAsync(channel, (x, y) => capture.Seen(nameof(conn), x, y)); + + // (R.4.1) If multiple member databases are configured, then I want to failover to the one with the highest weight. + var db = conn.GetDatabase(); + var ep = await db.IdentifyEndpointAsync(); + Assert.Equal(canada, ep); + + // now publish via all 4 options, see what happens + capture.Reset(); + await pub0.PublishAsync(channel, "abc"); + await pub1.PublishAsync(channel, "def"); + await pub2.PublishAsync(channel, "ghi"); + await multi.PublishAsync(channel, "jkl"); + + // we're expecting just canada, so: + await capture.AwaitAsync(multi, 6); + capture.AssertTakeAny("[pub0] chan: abc"); + capture.AssertTakeAny("[pub1] chan: def"); + capture.AssertTakeAny("[pub2] chan: ghi"); + capture.AssertTakeAny("[conn] chan: def"); // receives the message from pub1 + capture.AssertTakeAny("[conn] chan: jkl"); // receives the message from itself + capture.AssertTakeAny("[pub1] chan: jkl"); // received the message from the multi-group + + // change weight and update + members[1].Weight = 1; + typed.SelectPreferredGroup(); + ep = await db.IdentifyEndpointAsync(); + Assert.Equal(tokyo, ep); + + capture.Reset(); + log.WriteLine("Publishing..."); + await pub0.PublishAsync(channel, "abc"); + await pub1.PublishAsync(channel, "def"); + await pub2.PublishAsync(channel, "ghi"); + await multi.PublishAsync(channel, "jkl"); + + // now we're expecting just tokyo, so: + await capture.AwaitAsync(multi, 6); + capture.AssertTakeAny("[pub0] chan: abc"); + capture.AssertTakeAny("[pub1] chan: def"); + capture.AssertTakeAny("[pub2] chan: ghi"); + capture.AssertTakeAny("[conn] chan: ghi"); // receives the message from pub2 + capture.AssertTakeAny("[conn] chan: jkl"); // receives the message from itself + capture.AssertTakeAny("[pub2] chan: jkl"); // received the message from the multi-group + } + + [Fact] + public async Task PubSubOrderedRouted() + { + EndPoint germany = new DnsEndPoint("germany", 6379); + EndPoint canada = new DnsEndPoint("canada", 6379); + EndPoint tokyo = new DnsEndPoint("tokyo", 6379); + + using var server0 = new InProcessTestServer(log, endpoint: germany); + Assert.SkipUnless( + server0.GetClientConfig().CommandMap.IsAvailable(RedisCommand.PUBLISH), + "PUBLISH is not available"); + using var server1 = new InProcessTestServer(log, endpoint: canada); + using var server2 = new InProcessTestServer(log, endpoint: tokyo); + + Capture capture = new(log); + + RedisChannel channel = RedisChannel.Literal("chan"); + var pub0 = (await server0.ConnectAsync()).GetSubscriber(); + _ = capture.WriteSeen(nameof(pub0), await pub0.SubscribeAsync(channel)); + var pub1 = (await server1.ConnectAsync()).GetSubscriber(); + _ = capture.WriteSeen(nameof(pub1), await pub1.SubscribeAsync(channel)); + var pub2 = (await server2.ConnectAsync()).GetSubscriber(); + _ = capture.WriteSeen(nameof(pub2), await pub2.SubscribeAsync(channel)); + + ConnectionGroupMember[] members = + [ + new(server0.GetClientConfig()) { Weight = 2 }, + new(server1.GetClientConfig()) { Weight = 9 }, + new(server2.GetClientConfig()) { Weight = 3 }, + ]; + await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members); + Assert.True(conn.IsConnected); + var typed = Assert.IsType(conn); + var multi = conn.GetSubscriber(); + _ = capture.WriteSeen(nameof(conn), await multi.SubscribeAsync(channel)); + + // (R.4.1) If multiple member databases are configured, then I want to failover to the one with the highest weight. + var db = conn.GetDatabase(); + var ep = await db.IdentifyEndpointAsync(); + Assert.Equal(canada, ep); + + // now publish via all 4 options, see what happens + capture.Reset(); + await pub0.PublishAsync(channel, "abc"); + await pub1.PublishAsync(channel, "def"); + await pub2.PublishAsync(channel, "ghi"); + for (int i = 0; i < 5; i++) + { + await multi.PublishAsync(channel, $"jkl{i}"); + } + + // we're expecting just canada, so: + await capture.AwaitAsync(multi, 14); + capture.AssertTakeAny("[pub0] chan: abc"); + capture.AssertTakeAny("[pub1] chan: def"); + capture.AssertTakeAny("[pub2] chan: ghi"); + for (int i = 0; i < 5; i++) + { + capture.AssertTakeAny($"[pub1] chan: jkl{i}"); // received the message from the multi-group + } + // these should be ordered + capture.AssertTakeFirst("[conn] chan: def"); // receives the message from pub1 + for (int i = 0; i < 5; i++) + { + capture.AssertTakeFirst($"[conn] chan: jkl{i}"); // receives the message from itself + } + + // change weight and update + members[1].Weight = 1; + typed.SelectPreferredGroup(); + ep = await db.IdentifyEndpointAsync(); + Assert.Equal(tokyo, ep); + + capture.Reset(); + await pub0.PublishAsync(channel, "abc"); + await pub1.PublishAsync(channel, "def"); + await pub2.PublishAsync(channel, "ghi"); + for (int i = 0; i < 5; i++) + { + await multi.PublishAsync(channel, $"jkl{i}"); + } + + // now we're expecting just tokyo, so: + await capture.AwaitAsync(multi, 14); + capture.AssertTakeAny("[pub0] chan: abc"); + capture.AssertTakeAny("[pub1] chan: def"); + capture.AssertTakeAny("[pub2] chan: ghi"); + for (int i = 0; i < 5; i++) + { + capture.AssertTakeAny($"[pub2] chan: jkl{i}"); // received the message from the multi-group + } + + // these should be ordered + capture.AssertTakeFirst("[conn] chan: ghi"); // receives the message from pub2 + for (int i = 0; i < 5; i++) + { + capture.AssertTakeFirst($"[conn] chan: jkl{i}"); // receives the message from itself + } + } +} diff --git a/tests/StackExchange.Redis.Tests/RedisTestConfig.json b/tests/StackExchange.Redis.Tests/RedisTestConfig.json index c652a45838..31ae078189 100644 --- a/tests/StackExchange.Redis.Tests/RedisTestConfig.json +++ b/tests/StackExchange.Redis.Tests/RedisTestConfig.json @@ -3,4 +3,8 @@ //"PrimaryServer": "[::1]", //"ReplicaServer": "[::1]", //"SecureServer": "[::1]" + //"ActiveActiveEndpoints": [ + // "somewhere,password=sssshhh", + // "somewhereElse,password=sssshhh" + //] } \ No newline at end of file diff --git a/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj b/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj index c14c149d60..f20a2646ad 100644 --- a/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj +++ b/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj @@ -1,6 +1,6 @@  - + net481;net8.0;net10.0 Exe StackExchange.Redis.Tests @@ -10,7 +10,7 @@ enable true - + $(DefineConstants);BUILD_CURRENT diff --git a/toys/StackExchange.Redis.Server/MemoryCacheRedisServer.cs b/toys/StackExchange.Redis.Server/MemoryCacheRedisServer.cs index a8c0c22d10..62c8989b63 100644 --- a/toys/StackExchange.Redis.Server/MemoryCacheRedisServer.cs +++ b/toys/StackExchange.Redis.Server/MemoryCacheRedisServer.cs @@ -145,16 +145,26 @@ protected override RedisValue Get(int database, in RedisKey key) return RedisValue.Unbox(val); } - protected override void Set(int database, in RedisKey key, in RedisValue value) - => GetDb(database)[key] = value.Box(); - - protected override void SetEx(int database, in RedisKey key, TimeSpan expiration, in RedisValue value) + protected override bool Set(int database, in RedisKey key, in RedisValue value, TimeSpan? expiration = null, SetFlags flags = SetFlags.None) { var db = GetDb(database); + switch (flags & (SetFlags.NX | SetFlags.XX)) + { + case SetFlags.NX when Exists(database, key): return false; + case SetFlags.XX when !Exists(database, key): return false; + case SetFlags.NX | SetFlags.XX: throw new ArgumentOutOfRangeException(nameof(flags)); + } + + if (expiration is null) + { + db[key] = value.Box(); + return true; + } var now = Time(); - var absolute = now + expiration; + var absolute = now + expiration.Value; if (absolute <= now) db.Remove(key); else db[key] = new ExpiringValue(value.Box(), absolute); + return true; } protected override bool Del(int database, in RedisKey key) diff --git a/toys/StackExchange.Redis.Server/RedisServer.cs b/toys/StackExchange.Redis.Server/RedisServer.cs index a46705c224..26f1a2fc37 100644 --- a/toys/StackExchange.Redis.Server/RedisServer.cs +++ b/toys/StackExchange.Redis.Server/RedisServer.cs @@ -352,7 +352,7 @@ protected virtual TypedRedisValue SetEx(RedisClient client, in RedisRequest requ RedisKey key = request.GetKey(1); int seconds = request.GetInt32(2); var value = request.GetValue(3); - SetEx(client.Database, key, TimeSpan.FromSeconds(seconds), value); + Set(client.Database, key, value, TimeSpan.FromSeconds(seconds)); return TypedRedisValue.OK; } @@ -431,12 +431,6 @@ protected virtual TypedRedisValue Exec(RedisClient client, in RedisRequest reque return results; } - protected virtual void SetEx(int database, in RedisKey key, TimeSpan timeout, in RedisValue value) - { - Set(database, key, value); - Expire(database, key, timeout); - } - [RedisCommand(3, nameof(RedisCommand.CLIENT), "setname", LockFree = true)] protected virtual TypedRedisValue ClientSetname(RedisClient client, in RedisRequest request) { @@ -993,13 +987,37 @@ protected virtual TypedRedisValue Get(RedisClient client, in RedisRequest reques protected virtual RedisValue Get(int database, in RedisKey key) => throw new NotSupportedException(); - [RedisCommand(3)] + [RedisCommand(-3)] protected virtual TypedRedisValue Set(RedisClient client, in RedisRequest request) { - Set(client.Database, request.GetKey(1), request.GetValue(2)); - return TypedRedisValue.OK; + TimeSpan? expiry = null; + var key = request.GetKey(1); + var value = request.GetValue(2); + SetFlags flags = SetFlags.None; + for (int i = 3; i < request.Count; i++) + { + if (request.IsString(i, "nx"u8) || request.IsString(i, "NX"u8)) flags |= SetFlags.NX; + else if (request.IsString(i, "xx"u8) || request.IsString(i, "XX"u8)) flags |= SetFlags.XX; + else if (request.IsString(i, "ex"u8) || request.IsString(i, "EX"u8)) expiry = TimeSpan.FromSeconds(request.GetInt32(++i)); + else if (request.IsString(i, "px"u8) || request.IsString(i, "PX"u8)) expiry = TimeSpan.FromMilliseconds(request.GetInt32(++i)); + else return TypedRedisValue.Error("ERR syntax error"); + } + const SetFlags BOTH = SetFlags.NX | SetFlags.XX; + if ((flags & BOTH) == BOTH) return TypedRedisValue.Error("ERR Invalid flags combination"); + var result = Set(client.Database, request.GetKey(1), request.GetValue(2), expiry, flags); + return result ? TypedRedisValue.OK : TypedRedisValue.BulkString(RedisValue.Null); + } + + [Flags] + public enum SetFlags + { + None = 0, + NX = 1, + XX = 2, } - protected virtual void Set(int database, in RedisKey key, in RedisValue value) => throw new NotSupportedException(); + + protected virtual bool Set(int database, in RedisKey key, in RedisValue value, TimeSpan? expiry = null, SetFlags flags = SetFlags.None) => throw new NotSupportedException(); + [RedisCommand(1)] protected new virtual TypedRedisValue Shutdown(RedisClient client, in RedisRequest request) { diff --git a/toys/StackExchange.Redis.Server/RespServer.cs b/toys/StackExchange.Redis.Server/RespServer.cs index 51996721ea..800ec9fec9 100644 --- a/toys/StackExchange.Redis.Server/RespServer.cs +++ b/toys/StackExchange.Redis.Server/RespServer.cs @@ -331,6 +331,7 @@ public async Task RunClientAsync(IDuplexPipe pipe, RedisServer.Node node = null, } else { + await ClientPauseAsync(client); await client.AddOutboundAsync(response); } client.ResetAfterRequest(); @@ -397,6 +398,7 @@ internal static string GetUtf8String(in ReadOnlySequence buffer) charCount += Encoding.UTF8.GetChars(segment.Span, target.Slice(charCount)); } } + const string CR = "\u240D", LF = "\u240A", CRLF = CR + LF; string s = target.Slice(0, charCount).ToString() .Replace("\r\n", CRLF).Replace("\r", CR).Replace("\n", LF); @@ -416,6 +418,8 @@ public virtual void Log(string message) } } + protected virtual ValueTask ClientPauseAsync(RedisClient client) => default; + protected object ServerSyncLock => this; private long _totalCommandsProcesed, _totalErrorCount; From ac9ef4389d9fdba1d37fd218c9d0ef70f20950ed Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 25 Jun 2026 09:20:04 +0100 Subject: [PATCH 02/33] mix merge/branch-drift --- src/StackExchange.Redis/FrameworkShims.cs | 1 - .../MultiGroupDatabase.Arrays.Async.cs | 76 +++++++++++++++++++ .../MultiGroupDatabase.Arrays.cs | 74 ++++++++++++++++++ .../MultiGroupDatabase.Strings.Async.cs | 6 ++ .../MultiGroupDatabase.Strings.cs | 6 ++ .../RespReaderExtensions.cs | 8 -- 6 files changed, 162 insertions(+), 9 deletions(-) delete mode 100644 src/StackExchange.Redis/FrameworkShims.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.Arrays.Async.cs create mode 100644 src/StackExchange.Redis/MultiGroupDatabase.Arrays.cs diff --git a/src/StackExchange.Redis/FrameworkShims.cs b/src/StackExchange.Redis/FrameworkShims.cs deleted file mode 100644 index 5f282702bb..0000000000 --- a/src/StackExchange.Redis/FrameworkShims.cs +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Arrays.Async.cs b/src/StackExchange.Redis/MultiGroupDatabase.Arrays.Async.cs new file mode 100644 index 0000000000..01951451fe --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.Arrays.Async.cs @@ -0,0 +1,76 @@ +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // Array Async operations + public Task ArraySetAsync(RedisKey key, RedisArrayIndex index, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArraySetAsync(key, index, value, flags); + + public Task ArraySetAsync(RedisKey key, RedisArrayIndex index, RedisValue[] values, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArraySetAsync(key, index, values, flags); + + public Task ArraySetAsync(RedisKey key, RedisArrayEntry[] values, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArraySetAsync(key, values, flags); + + public Task ArrayGetAsync(RedisKey key, RedisArrayIndex index, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayGetAsync(key, index, flags); + + public Task ArrayGetAsync(RedisKey key, RedisArrayIndex[] indices, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayGetAsync(key, indices, flags); + + public Task ArrayGetRangeAsync(RedisKey key, RedisArrayIndex start, RedisArrayIndex end, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayGetRangeAsync(key, start, end, flags); + + public Task ArrayLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayLengthAsync(key, flags); + + public Task ArrayCountAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayCountAsync(key, flags); + + public Task ArrayDeleteAsync(RedisKey key, RedisArrayIndex index, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayDeleteAsync(key, index, flags); + + public Task ArrayDeleteAsync(RedisKey key, RedisArrayIndex[] indices, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayDeleteAsync(key, indices, flags); + + public Task ArrayDeleteRangeAsync(RedisKey key, RedisArrayIndex start, RedisArrayIndex end, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayDeleteRangeAsync(key, start, end, flags); + + public Task ArrayDeleteRangeAsync(RedisKey key, RedisArrayRange[] ranges, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayDeleteRangeAsync(key, ranges, flags); + + public Task ArrayScanAsync(RedisKey key, RedisArrayIndex start, RedisArrayIndex end, int limit = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayScanAsync(key, start, end, limit, flags); + + public Task ArrayGrepAsync(RedisKey key, ArrayGrepRequest request, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayGrepAsync(key, request, flags); + + public Task ArrayOperationAsync(RedisKey key, RedisArrayIndex start, RedisArrayIndex end, ArrayOperation operation, RedisValue operand = default, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayOperationAsync(key, start, end, operation, operand, flags); + + public Task ArrayRingAsync(RedisKey key, RedisArrayIndex maxLength, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayRingAsync(key, maxLength, value, flags); + + public Task ArrayRingAsync(RedisKey key, RedisArrayIndex maxLength, RedisValue[] values, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayRingAsync(key, maxLength, values, flags); + + public Task ArrayNextAsync(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayNextAsync(key, flags); + + public Task ArrayInsertAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayInsertAsync(key, value, flags); + + public Task ArrayInsertAsync(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayInsertAsync(key, values, flags); + + public Task ArraySeekAsync(RedisKey key, RedisArrayIndex index, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArraySeekAsync(key, index, flags); + + public Task ArrayLastItemsAsync(RedisKey key, int count, bool reverse = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayLastItemsAsync(key, count, reverse, flags); + + public Task ArrayInfoAsync(RedisKey key, bool full = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayInfoAsync(key, full, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Arrays.cs b/src/StackExchange.Redis/MultiGroupDatabase.Arrays.cs new file mode 100644 index 0000000000..6f5688ddc9 --- /dev/null +++ b/src/StackExchange.Redis/MultiGroupDatabase.Arrays.cs @@ -0,0 +1,74 @@ +namespace StackExchange.Redis; + +internal sealed partial class MultiGroupDatabase +{ + // Array operations + public bool ArraySet(RedisKey key, RedisArrayIndex index, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArraySet(key, index, value, flags); + + public int ArraySet(RedisKey key, RedisArrayIndex index, RedisValue[] values, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArraySet(key, index, values, flags); + + public int ArraySet(RedisKey key, RedisArrayEntry[] values, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArraySet(key, values, flags); + + public RedisValue ArrayGet(RedisKey key, RedisArrayIndex index, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayGet(key, index, flags); + + public RedisValue[] ArrayGet(RedisKey key, RedisArrayIndex[] indices, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayGet(key, indices, flags); + + public RedisValue[] ArrayGetRange(RedisKey key, RedisArrayIndex start, RedisArrayIndex end, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayGetRange(key, start, end, flags); + + public RedisArrayIndex ArrayLength(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayLength(key, flags); + + public RedisArrayIndex ArrayCount(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayCount(key, flags); + + public bool ArrayDelete(RedisKey key, RedisArrayIndex index, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayDelete(key, index, flags); + + public int ArrayDelete(RedisKey key, RedisArrayIndex[] indices, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayDelete(key, indices, flags); + + public RedisArrayIndex ArrayDeleteRange(RedisKey key, RedisArrayIndex start, RedisArrayIndex end, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayDeleteRange(key, start, end, flags); + + public RedisArrayIndex ArrayDeleteRange(RedisKey key, RedisArrayRange[] ranges, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayDeleteRange(key, ranges, flags); + + public RedisArrayEntry[] ArrayScan(RedisKey key, RedisArrayIndex start, RedisArrayIndex end, int limit = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayScan(key, start, end, limit, flags); + + public RedisArrayEntry[] ArrayGrep(RedisKey key, ArrayGrepRequest request, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayGrep(key, request, flags); + + public RedisValue ArrayOperation(RedisKey key, RedisArrayIndex start, RedisArrayIndex end, ArrayOperation operation, RedisValue operand = default, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayOperation(key, start, end, operation, operand, flags); + + public RedisArrayIndex ArrayRing(RedisKey key, RedisArrayIndex maxLength, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayRing(key, maxLength, value, flags); + + public RedisArrayIndex ArrayRing(RedisKey key, RedisArrayIndex maxLength, RedisValue[] values, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayRing(key, maxLength, values, flags); + + public RedisArrayIndex? ArrayNext(RedisKey key, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayNext(key, flags); + + public RedisArrayIndex ArrayInsert(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayInsert(key, value, flags); + + public RedisArrayIndex ArrayInsert(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayInsert(key, values, flags); + + public bool ArraySeek(RedisKey key, RedisArrayIndex index, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArraySeek(key, index, flags); + + public RedisValue[] ArrayLastItems(RedisKey key, int count, bool reverse = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayLastItems(key, count, reverse, flags); + + public ArrayInfo ArrayInfo(RedisKey key, bool full = false, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().ArrayInfo(key, full, flags); +} diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Strings.Async.cs b/src/StackExchange.Redis/MultiGroupDatabase.Strings.Async.cs index 9dba49a82d..044fffb2f7 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.Strings.Async.cs +++ b/src/StackExchange.Redis/MultiGroupDatabase.Strings.Async.cs @@ -75,6 +75,12 @@ public Task StringIncrementAsync(RedisKey key, long value = 1, CommandFlag public Task StringIncrementAsync(RedisKey key, double value, CommandFlags flags = CommandFlags.None) => GetActiveDatabase().StringIncrementAsync(key, value, flags); + public Task> StringIncrementAsync(RedisKey key, long value, Expiration expiry, long? lowerBound = null, long? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringIncrementAsync(key, value, expiry, lowerBound, upperBound, options, flags); + + public Task> StringIncrementAsync(RedisKey key, double value, Expiration expiry, double? lowerBound = null, double? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringIncrementAsync(key, value, expiry, lowerBound, upperBound, options, flags); + public Task StringLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) => GetActiveDatabase().StringLengthAsync(key, flags); diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Strings.cs b/src/StackExchange.Redis/MultiGroupDatabase.Strings.cs index ffab627875..98e3d9ccca 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.Strings.cs +++ b/src/StackExchange.Redis/MultiGroupDatabase.Strings.cs @@ -74,6 +74,12 @@ public long StringIncrement(RedisKey key, long value = 1, CommandFlags flags = C public double StringIncrement(RedisKey key, double value, CommandFlags flags = CommandFlags.None) => GetActiveDatabase().StringIncrement(key, value, flags); + public StringIncrementResult StringIncrement(RedisKey key, long value, Expiration expiry, long? lowerBound = null, long? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringIncrement(key, value, expiry, lowerBound, upperBound, options, flags); + + public StringIncrementResult StringIncrement(RedisKey key, double value, Expiration expiry, double? lowerBound = null, double? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().StringIncrement(key, value, expiry, lowerBound, upperBound, options, flags); + public long StringLength(RedisKey key, CommandFlags flags = CommandFlags.None) => GetActiveDatabase().StringLength(key, flags); diff --git a/src/StackExchange.Redis/RespReaderExtensions.cs b/src/StackExchange.Redis/RespReaderExtensions.cs index 6f80c57a44..ce1f087343 100644 --- a/src/StackExchange.Redis/RespReaderExtensions.cs +++ b/src/StackExchange.Redis/RespReaderExtensions.cs @@ -1,7 +1,6 @@ using System; using System.Buffers; using System.Diagnostics; -using System.Threading.Tasks; using RESPite.Messages; namespace StackExchange.Redis; @@ -201,11 +200,4 @@ internal bool AnyNull() return false; } } - -#if !NET - extension(Task task) - { - public bool IsCompletedSuccessfully => task.Status is TaskStatus.RanToCompletion; - } -#endif } From 185e98c8bc2b1a777c45b13cbd971231ca4e454f Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 29 Jun 2026 09:21:26 +0100 Subject: [PATCH 03/33] remove duplicated extension property --- src/StackExchange.Redis/RespReaderExtensions.cs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/StackExchange.Redis/RespReaderExtensions.cs b/src/StackExchange.Redis/RespReaderExtensions.cs index 2107d03977..5683208eb3 100644 --- a/src/StackExchange.Redis/RespReaderExtensions.cs +++ b/src/StackExchange.Redis/RespReaderExtensions.cs @@ -234,13 +234,6 @@ internal bool AnyNull() } } -#if !NET - extension(Task task) - { - public bool IsCompletedSuccessfully => task.Status is TaskStatus.RanToCompletion; - } -#endif - private static readonly int MaxCanonicalLength = Math.Max(Format.MaxInt64TextLen, Format.MaxDoubleTextLen); // Recognizes the canonical decimal text of an integer (signed Int64 or, for non-negative values up to From 2ffd24675188572833515f1be408f557f86fb9c0 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 29 Jun 2026 13:07:48 +0100 Subject: [PATCH 04/33] fix latency failing to update --- src/RESPite/Shared/AsciiHash.Instance.cs | 26 ++++++++++++++++++- src/StackExchange.Redis/Enums/RedisCommand.cs | 3 +++ .../MultiGroupMultiplexer.cs | 6 +++++ .../InProcessTestServer.cs | 13 +++++++--- .../MultiGroupTests/BasicMultiGroupTests.cs | 14 +++++++--- toys/StackExchange.Redis.Server/RespServer.cs | 6 ++--- .../StackExchange.Redis.Server.csproj | 1 + 7 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/RESPite/Shared/AsciiHash.Instance.cs b/src/RESPite/Shared/AsciiHash.Instance.cs index 50ac4d2452..93b8258cad 100644 --- a/src/RESPite/Shared/AsciiHash.Instance.cs +++ b/src/RESPite/Shared/AsciiHash.Instance.cs @@ -4,7 +4,12 @@ namespace RESPite; -public readonly partial struct AsciiHash : IEquatable +public readonly partial struct AsciiHash : IEquatable, +#if NET + ISpanFormattable +#else + IFormattable +#endif { // ReSharper disable InconsistentNaming private readonly long _hashCS, _hashUC; @@ -71,4 +76,23 @@ public bool IsCI(ReadOnlySpan value) if (uc != _hashUC | value.Length != len) return false; return len <= MaxBytesHashed || SequenceEqualsCI(Span, value); } + + string IFormattable.ToString(string? format, IFormatProvider? formatProvider) => ToString(); + +#if NET + bool ISpanFormattable.TryFormat( + Span destination, + out int charsWritten, + ReadOnlySpan format, + IFormatProvider? provider) + { + charsWritten = 0; + var source = Span; + if (source.IsEmpty) return true; + if (source.Length > destination.Length) return false; + + charsWritten = Encoding.ASCII.GetChars(source, destination); + return true; + } +#endif } diff --git a/src/StackExchange.Redis/Enums/RedisCommand.cs b/src/StackExchange.Redis/Enums/RedisCommand.cs index 863904f936..172949999e 100644 --- a/src/StackExchange.Redis/Enums/RedisCommand.cs +++ b/src/StackExchange.Redis/Enums/RedisCommand.cs @@ -310,6 +310,9 @@ internal static partial class RedisCommandMetadata [AsciiHash(CaseSensitive = false)] public static partial bool TryParseCI(ReadOnlySpan command, out RedisCommand value); + + [AsciiHash] + public static partial bool TryFormat(RedisCommand command, out string format); } // ReSharper restore InconsistentNaming diff --git a/src/StackExchange.Redis/MultiGroupMultiplexer.cs b/src/StackExchange.Redis/MultiGroupMultiplexer.cs index ab85d688d2..0bc24a36be 100644 --- a/src/StackExchange.Redis/MultiGroupMultiplexer.cs +++ b/src/StackExchange.Redis/MultiGroupMultiplexer.cs @@ -210,6 +210,11 @@ internal GroupConnectionChangedEventArgs.ChangeType UpdateState(HealthCheck.Heal : isConnected ? GroupConnectionChangedEventArgs.ChangeType.Reconnected : GroupConnectionChangedEventArgs.ChangeType.Disconnected; } + + internal void UpdateLatency() + { + if (_muxer is { } muxer) SetLatency(muxer.UpdateLatency()); + } } internal sealed partial class MultiGroupMultiplexer : IConnectionGroup @@ -415,6 +420,7 @@ internal void SelectPreferredGroup() if (member.IsConnected) { + member.UpdateLatency(); // this can change passively preferredMember = ConnectionGroupMember.Select(preferredMember, member); } } diff --git a/tests/StackExchange.Redis.Tests/InProcessTestServer.cs b/tests/StackExchange.Redis.Tests/InProcessTestServer.cs index 51faaa7fb7..2b14692beb 100644 --- a/tests/StackExchange.Redis.Tests/InProcessTestServer.cs +++ b/tests/StackExchange.Redis.Tests/InProcessTestServer.cs @@ -196,10 +196,10 @@ public override void OnFlush(RedisClient client, int messages, long bytes) } */ - public override TypedRedisValue OnUnknownCommand(in RedisClient client, in RedisRequest request, ReadOnlySpan command) + public override TypedRedisValue OnUnknownCommand(RedisClient client, in RedisRequest request, ReadOnlySpan command) { _log?.WriteLine($"[{client}] unknown command: {Encoding.ASCII.GetString(command)}"); - return base.OnUnknownCommand(in client, in request, command); + return base.OnUnknownCommand(client, in request, command); } public override void OnClientConnected(RedisClient client, object state) @@ -407,9 +407,14 @@ protected override void Dispose(bool disposing) private TimeSpan _latency = TimeSpan.Zero; - protected override ValueTask ClientPauseAsync(RedisClient client) + protected override ValueTask ClientPauseAsync(RedisClient client, in RedisRequest request) { var latency = _latency; - return latency <= TimeSpan.Zero ? base.ClientPauseAsync(client) : new(Task.Delay(latency)); + if (latency > TimeSpan.Zero & request.KnownCommand != RedisCommand.QUIT) + { + Log($"[{client}] holding {request.Command} response by {latency.TotalMilliseconds}ms"); + return new(Task.Delay(latency)); + } + return base.ClientPauseAsync(client, request); } } diff --git a/tests/StackExchange.Redis.Tests/MultiGroupTests/BasicMultiGroupTests.cs b/tests/StackExchange.Redis.Tests/MultiGroupTests/BasicMultiGroupTests.cs index d72e3a34f1..7072508599 100644 --- a/tests/StackExchange.Redis.Tests/MultiGroupTests/BasicMultiGroupTests.cs +++ b/tests/StackExchange.Redis.Tests/MultiGroupTests/BasicMultiGroupTests.cs @@ -155,6 +155,7 @@ private void WriteLatency(IConnectionGroup conn) var typed = Assert.IsType(conn); foreach (var member in conn.GetMembers()) { + member.UpdateLatency(); log.WriteLine($"{member.Name}: {member.Latency.TotalMilliseconds}us"); } log.WriteLine($"Active: {typed.Active}"); @@ -171,10 +172,15 @@ public async Task SelectByLatency() using var server1 = new InProcessTestServer(log, endpoint: canada); using var server2 = new InProcessTestServer(log, endpoint: tokyo); + static ConfigurationOptions Check(ConfigurationOptions options) + { + // options.HeartbeatConsistencyChecks = true; + return options; + } ConnectionGroupMember[] members = [ - new(server0.GetClientConfig()), - new(server1.GetClientConfig()), - new(server2.GetClientConfig()), + new(Check(server0.GetClientConfig())), + new(Check(server1.GetClientConfig())), + new(Check(server2.GetClientConfig())), ]; await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members); conn.ConnectionChanged += (_, args) => log.WriteLine($"Connection changed: {args.Type}, from {args.PreviousGroup?.Name ?? "(nil)"} to {args.Group.Name}"); @@ -189,7 +195,7 @@ public async Task SelectByLatency() typed.SelectPreferredGroup(); WriteLatency(typed); - // (R.4.1) If multiple member databases are configured, then I want to failover to the one with the highest weight. + // select lowest latency var db = conn.GetDatabase(); var ep = await db.IdentifyEndpointAsync(); Assert.Equal(canada, ep); diff --git a/toys/StackExchange.Redis.Server/RespServer.cs b/toys/StackExchange.Redis.Server/RespServer.cs index 800ec9fec9..cb98ead2b4 100644 --- a/toys/StackExchange.Redis.Server/RespServer.cs +++ b/toys/StackExchange.Redis.Server/RespServer.cs @@ -331,7 +331,7 @@ public async Task RunClientAsync(IDuplexPipe pipe, RedisServer.Node node = null, } else { - await ClientPauseAsync(client); + await ClientPauseAsync(client, request); await client.AddOutboundAsync(response); } client.ResetAfterRequest(); @@ -418,7 +418,7 @@ public virtual void Log(string message) } } - protected virtual ValueTask ClientPauseAsync(RedisClient client) => default; + protected virtual ValueTask ClientPauseAsync(RedisClient client, in RedisRequest request) => default; protected object ServerSyncLock => this; @@ -431,7 +431,7 @@ public virtual void ResetCounters() _totalCommandsProcesed = _totalErrorCount = _totalClientCount = 0; } - public virtual TypedRedisValue OnUnknownCommand(in RedisClient client, in RedisRequest request, ReadOnlySpan command) + public virtual TypedRedisValue OnUnknownCommand(RedisClient client, in RedisRequest request, ReadOnlySpan command) { return request.CommandNotFound(); } diff --git a/toys/StackExchange.Redis.Server/StackExchange.Redis.Server.csproj b/toys/StackExchange.Redis.Server/StackExchange.Redis.Server.csproj index 3e800c817d..0bcc55d753 100644 --- a/toys/StackExchange.Redis.Server/StackExchange.Redis.Server.csproj +++ b/toys/StackExchange.Redis.Server/StackExchange.Redis.Server.csproj @@ -15,5 +15,6 @@ + From 6f4c68d418f23ce9a3561b5bec651a37d0b3e051 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 29 Jun 2026 14:38:02 +0100 Subject: [PATCH 05/33] nits --- .../ConnectionMultiplexer.cs | 6 ++ .../HealthCheck.Execute.cs | 7 +- .../HealthCheck.HealthCheckProbe.cs | 5 + .../HealthCheck.HealthCheckProbeContext.cs | 3 + .../HealthCheck.HealthCheckProbePolicy.cs | 3 + src/StackExchange.Redis/HealthCheck.cs | 3 + .../MultiGroupMultiplexer.cs | 27 +++-- .../MultiGroupSubscriber.Tracking.cs | 5 + .../PublicAPI/PublicAPI.Unshipped.txt | 100 +++++++++--------- 9 files changed, 101 insertions(+), 58 deletions(-) diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index bc618b1842..2b00ba3b6f 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -2367,6 +2367,12 @@ private Task[] QuitAllServers() internal uint UpdateLatency() { + // Per-server latency is captured passively during the critical handshake (see + // ServerEndPoint.SetLatency), so the values read here are kept current without us issuing + // any extra traffic. We aggregate to the *worst* (max) connected server, so a group is only + // rated as fast as its slowest endpoint. Note that uint.MaxValue doubles as the "not yet + // measured" sentinel: if no connected server has a real measurement we leave the previously + // published value untouched rather than reporting a spurious MaxValue. var snapshot = GetServerSnapshot(); uint max = uint.MaxValue; foreach (var server in snapshot) diff --git a/src/StackExchange.Redis/HealthCheck.Execute.cs b/src/StackExchange.Redis/HealthCheck.Execute.cs index d228a253ab..29070233bd 100644 --- a/src/StackExchange.Redis/HealthCheck.Execute.cs +++ b/src/StackExchange.Redis/HealthCheck.Execute.cs @@ -168,11 +168,14 @@ private async Task CheckHealthCoreAsync(IServer server) if (remaining > 0) { - // delay if appropriate + // delay between probes when the policy calls for it (today: only after a failure). + // Note: TotalTimeoutMillis() assumes this effective interval equals ProbeInterval; if + // the policy ever returns a variable interval (backoff/jitter), the budget formula must + // be revisited in lockstep, else the outer timeout can fire and mark a member unhealthy. var delay = ProbePolicy.GetEffectiveProbeInterval(in ctx); if (delay > TimeSpan.Zero) { - await Task.Delay(ctx.ProbeInterval).ConfigureAwait(false); + await Task.Delay(delay).ConfigureAwait(false); } } } diff --git a/src/StackExchange.Redis/HealthCheck.HealthCheckProbe.cs b/src/StackExchange.Redis/HealthCheck.HealthCheckProbe.cs index 1d3e4f1a46..717863150d 100644 --- a/src/StackExchange.Redis/HealthCheck.HealthCheckProbe.cs +++ b/src/StackExchange.Redis/HealthCheck.HealthCheckProbe.cs @@ -1,6 +1,8 @@ using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Net; using System.Threading.Tasks; +using RESPite; namespace StackExchange.Redis; @@ -9,6 +11,7 @@ public sealed partial class HealthCheck /// /// Describes an operation to perform as part of a health check. /// + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] public abstract partial class HealthCheckProbe { /// @@ -37,6 +40,7 @@ public abstract partial class HealthCheckProbe /// /// Describes a key-based (write) operation to perform as part of a health check. /// + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] public abstract class KeyWriteHealthCheckProbe : HealthCheckProbe { /// @@ -59,6 +63,7 @@ public override Task CheckHealthAsync(HealthCheck healthCheck /// /// Indicates the result of a health check. /// + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] public enum HealthCheckResult { /// diff --git a/src/StackExchange.Redis/HealthCheck.HealthCheckProbeContext.cs b/src/StackExchange.Redis/HealthCheck.HealthCheckProbeContext.cs index 3766e6e58a..a0e6a58a35 100644 --- a/src/StackExchange.Redis/HealthCheck.HealthCheckProbeContext.cs +++ b/src/StackExchange.Redis/HealthCheck.HealthCheckProbeContext.cs @@ -1,5 +1,7 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +using RESPite; namespace StackExchange.Redis; @@ -8,6 +10,7 @@ public sealed partial class HealthCheck /// /// Represents the context of a health check probe. /// + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] public readonly struct HealthCheckProbeContext(HealthCheckResult result, int success, int failure, int remaining, TimeSpan probeInterval) { /// diff --git a/src/StackExchange.Redis/HealthCheck.HealthCheckProbePolicy.cs b/src/StackExchange.Redis/HealthCheck.HealthCheckProbePolicy.cs index b9edd2eaeb..c303bf8edc 100644 --- a/src/StackExchange.Redis/HealthCheck.HealthCheckProbePolicy.cs +++ b/src/StackExchange.Redis/HealthCheck.HealthCheckProbePolicy.cs @@ -1,4 +1,6 @@ using System; +using System.Diagnostics.CodeAnalysis; +using RESPite; namespace StackExchange.Redis; @@ -7,6 +9,7 @@ public sealed partial class HealthCheck /// /// Attempt to evaluate the outcome of a series of health check operations. /// + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] public abstract class HealthCheckProbePolicy { /// diff --git a/src/StackExchange.Redis/HealthCheck.cs b/src/StackExchange.Redis/HealthCheck.cs index 3f8dae3afc..4cae83f6e6 100644 --- a/src/StackExchange.Redis/HealthCheck.cs +++ b/src/StackExchange.Redis/HealthCheck.cs @@ -1,12 +1,15 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Threading; +using RESPite; namespace StackExchange.Redis; /// /// Describes a health check to perform against instances. /// +[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] public sealed partial class HealthCheck : ICloneable { private static HealthCheck? _default; diff --git a/src/StackExchange.Redis/MultiGroupMultiplexer.cs b/src/StackExchange.Redis/MultiGroupMultiplexer.cs index 0bc24a36be..ecc7883daa 100644 --- a/src/StackExchange.Redis/MultiGroupMultiplexer.cs +++ b/src/StackExchange.Redis/MultiGroupMultiplexer.cs @@ -340,14 +340,23 @@ internal static async Task TryHealthCheckAndSelectPreferredGroupAsync(obje private void StartPolling() { - // use a weak-ref to avoid the loop keeping the object alive - _ = Task.Run(() => PollAsync(new(this))); + // use a weak-ref to avoid the loop keeping the object alive; capturing the token (rather than + // the muxer) lets us break out of the delay promptly on dispose without resurrecting the target + var cancellationToken = _pollCancellation.Token; + _ = Task.Run(() => PollAsync(new(this), cancellationToken)); - static async Task PollAsync(WeakReference weakRef) + static async Task PollAsync(WeakReference weakRef, CancellationToken cancellationToken) { while (TryGetHealthCheck(weakRef.Target, out var interval)) { - await Task.Delay(interval).ForAwait(); + try + { + await Task.Delay(interval, cancellationToken).ForAwait(); + } + catch (OperationCanceledException) + { + break; // disposed; stop polling without waiting out the interval + } if (!await TryHealthCheckAndSelectPreferredGroupAsync(weakRef.Target).ForAwait()) break; } } @@ -436,8 +445,11 @@ internal void SelectPreferredGroup() } } + private readonly CancellationTokenSource _pollCancellation = new(); + private List DropAll() { + _pollCancellation.Cancel(); // stop the polling loop promptly (idempotent) _active = null; var members = Interlocked.Exchange(ref _members, []); if (members.Length is 0) return []; @@ -495,8 +507,11 @@ public bool PreserveAsyncOrder set => Active.PreserveAsyncOrder = value; } - public bool IsConnected => Active.IsConnected; - public bool IsConnecting => Active.IsConnecting; + // Unlike most members, these intentionally do *not* go via Active (which throws when no member is + // available); callers routinely use IsConnected/IsConnecting as a pre-flight check and expect a + // 'false' result - not an exception - when the entire group is down. + public bool IsConnected => _active?.IsConnected ?? false; + public bool IsConnecting => _active?.IsConnecting ?? false; [Obsolete] public bool IncludeDetailInExceptions diff --git a/src/StackExchange.Redis/MultiGroupSubscriber.Tracking.cs b/src/StackExchange.Redis/MultiGroupSubscriber.Tracking.cs index 30653e8620..98fcf7ddf4 100644 --- a/src/StackExchange.Redis/MultiGroupSubscriber.Tracking.cs +++ b/src/StackExchange.Redis/MultiGroupSubscriber.Tracking.cs @@ -217,6 +217,11 @@ private async Task AddPubSubHandlersAsync(ConnectionGroupMember member) ForwardFilteredMessages(this, member, tuple.Queue, from); } } + + // Deliberately *not* in a try/finally: if an await above faults (e.g. timeout/cancellation), the + // in-flight async operation may still hold a reference to this array, so returning it to the pool + // could hand the same buffer to another renter while it is still being read. We only return it on + // the clean path; on the (rare) fault path we simply let it be collected. ArrayPool.Shared.Return(lease); } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 852e623a2e..e8acf4e023 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,55 +1,55 @@ #nullable enable -abstract StackExchange.Redis.HealthCheck.HealthCheckProbe.CheckHealthAsync(StackExchange.Redis.HealthCheck! healthCheck, StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! -abstract StackExchange.Redis.HealthCheck.HealthCheckProbePolicy.Evaluate(in StackExchange.Redis.HealthCheck.HealthCheckProbeContext context) -> StackExchange.Redis.HealthCheck.HealthCheckResult -abstract StackExchange.Redis.HealthCheck.KeyWriteHealthCheckProbe.CheckHealthAsync(StackExchange.Redis.HealthCheck! healthCheck, StackExchange.Redis.IDatabaseAsync! database, StackExchange.Redis.RedisKey key) -> System.Threading.Tasks.Task! -override StackExchange.Redis.HealthCheck.HealthCheckProbeContext.ToString() -> string! -override StackExchange.Redis.HealthCheck.KeyWriteHealthCheckProbe.CheckHealthAsync(StackExchange.Redis.HealthCheck! healthCheck, StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! -StackExchange.Redis.HealthCheck -StackExchange.Redis.HealthCheck.CheckHealthAsync(StackExchange.Redis.IConnectionMultiplexer! multiplexer) -> System.Threading.Tasks.Task! -StackExchange.Redis.HealthCheck.CheckHealthAsync(StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! -StackExchange.Redis.HealthCheck.Clone() -> StackExchange.Redis.HealthCheck! -StackExchange.Redis.HealthCheck.HealthCheck() -> void -StackExchange.Redis.HealthCheck.HealthCheckProbe -StackExchange.Redis.HealthCheck.HealthCheckProbe.HealthCheckProbe() -> void -StackExchange.Redis.HealthCheck.HealthCheckProbeContext -StackExchange.Redis.HealthCheck.HealthCheckProbeContext.Failure.get -> int -StackExchange.Redis.HealthCheck.HealthCheckProbeContext.HealthCheckProbeContext() -> void -StackExchange.Redis.HealthCheck.HealthCheckProbeContext.HealthCheckProbeContext(StackExchange.Redis.HealthCheck.HealthCheckResult result, int success, int failure, int remaining, System.TimeSpan probeInterval) -> void -StackExchange.Redis.HealthCheck.HealthCheckProbeContext.ProbeInterval.get -> System.TimeSpan -StackExchange.Redis.HealthCheck.HealthCheckProbeContext.Remaining.get -> int -StackExchange.Redis.HealthCheck.HealthCheckProbeContext.Result.get -> StackExchange.Redis.HealthCheck.HealthCheckResult -StackExchange.Redis.HealthCheck.HealthCheckProbeContext.Success.get -> int -StackExchange.Redis.HealthCheck.HealthCheckProbePolicy -StackExchange.Redis.HealthCheck.HealthCheckProbePolicy.HealthCheckProbePolicy() -> void -StackExchange.Redis.HealthCheck.HealthCheckResult -StackExchange.Redis.HealthCheck.HealthCheckResult.Healthy = 1 -> StackExchange.Redis.HealthCheck.HealthCheckResult -StackExchange.Redis.HealthCheck.HealthCheckResult.Inconclusive = 0 -> StackExchange.Redis.HealthCheck.HealthCheckResult -StackExchange.Redis.HealthCheck.HealthCheckResult.Unhealthy = 2 -> StackExchange.Redis.HealthCheck.HealthCheckResult -StackExchange.Redis.HealthCheck.Interval.get -> System.TimeSpan -StackExchange.Redis.HealthCheck.Interval.set -> void -StackExchange.Redis.HealthCheck.KeyWriteHealthCheckProbe -StackExchange.Redis.HealthCheck.KeyWriteHealthCheckProbe.KeyWriteHealthCheckProbe() -> void -StackExchange.Redis.HealthCheck.Probe.get -> StackExchange.Redis.HealthCheck.HealthCheckProbe! -StackExchange.Redis.HealthCheck.Probe.set -> void -StackExchange.Redis.HealthCheck.ProbeCount.get -> int -StackExchange.Redis.HealthCheck.ProbeCount.set -> void -StackExchange.Redis.HealthCheck.ProbeInterval.get -> System.TimeSpan -StackExchange.Redis.HealthCheck.ProbeInterval.set -> void -StackExchange.Redis.HealthCheck.ProbePolicy.get -> StackExchange.Redis.HealthCheck.HealthCheckProbePolicy! -StackExchange.Redis.HealthCheck.ProbePolicy.set -> void -StackExchange.Redis.HealthCheck.ProbeTimeout.get -> System.TimeSpan -StackExchange.Redis.HealthCheck.ProbeTimeout.set -> void +[SER007]abstract StackExchange.Redis.HealthCheck.HealthCheckProbe.CheckHealthAsync(StackExchange.Redis.HealthCheck! healthCheck, StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! +[SER007]abstract StackExchange.Redis.HealthCheck.HealthCheckProbePolicy.Evaluate(in StackExchange.Redis.HealthCheck.HealthCheckProbeContext context) -> StackExchange.Redis.HealthCheck.HealthCheckResult +[SER007]abstract StackExchange.Redis.HealthCheck.KeyWriteHealthCheckProbe.CheckHealthAsync(StackExchange.Redis.HealthCheck! healthCheck, StackExchange.Redis.IDatabaseAsync! database, StackExchange.Redis.RedisKey key) -> System.Threading.Tasks.Task! +[SER007]override StackExchange.Redis.HealthCheck.HealthCheckProbeContext.ToString() -> string! +[SER007]override StackExchange.Redis.HealthCheck.KeyWriteHealthCheckProbe.CheckHealthAsync(StackExchange.Redis.HealthCheck! healthCheck, StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! +[SER007]StackExchange.Redis.HealthCheck +[SER007]StackExchange.Redis.HealthCheck.CheckHealthAsync(StackExchange.Redis.IConnectionMultiplexer! multiplexer) -> System.Threading.Tasks.Task! +[SER007]StackExchange.Redis.HealthCheck.CheckHealthAsync(StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! +[SER007]StackExchange.Redis.HealthCheck.Clone() -> StackExchange.Redis.HealthCheck! +[SER007]StackExchange.Redis.HealthCheck.HealthCheck() -> void +[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbe +[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbe.HealthCheckProbe() -> void +[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbeContext +[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbeContext.Failure.get -> int +[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbeContext.HealthCheckProbeContext() -> void +[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbeContext.HealthCheckProbeContext(StackExchange.Redis.HealthCheck.HealthCheckResult result, int success, int failure, int remaining, System.TimeSpan probeInterval) -> void +[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbeContext.ProbeInterval.get -> System.TimeSpan +[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbeContext.Remaining.get -> int +[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbeContext.Result.get -> StackExchange.Redis.HealthCheck.HealthCheckResult +[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbeContext.Success.get -> int +[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbePolicy +[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbePolicy.HealthCheckProbePolicy() -> void +[SER007]StackExchange.Redis.HealthCheck.HealthCheckResult +[SER007]StackExchange.Redis.HealthCheck.HealthCheckResult.Healthy = 1 -> StackExchange.Redis.HealthCheck.HealthCheckResult +[SER007]StackExchange.Redis.HealthCheck.HealthCheckResult.Inconclusive = 0 -> StackExchange.Redis.HealthCheck.HealthCheckResult +[SER007]StackExchange.Redis.HealthCheck.HealthCheckResult.Unhealthy = 2 -> StackExchange.Redis.HealthCheck.HealthCheckResult +[SER007]StackExchange.Redis.HealthCheck.Interval.get -> System.TimeSpan +[SER007]StackExchange.Redis.HealthCheck.Interval.set -> void +[SER007]StackExchange.Redis.HealthCheck.KeyWriteHealthCheckProbe +[SER007]StackExchange.Redis.HealthCheck.KeyWriteHealthCheckProbe.KeyWriteHealthCheckProbe() -> void +[SER007]StackExchange.Redis.HealthCheck.Probe.get -> StackExchange.Redis.HealthCheck.HealthCheckProbe! +[SER007]StackExchange.Redis.HealthCheck.Probe.set -> void +[SER007]StackExchange.Redis.HealthCheck.ProbeCount.get -> int +[SER007]StackExchange.Redis.HealthCheck.ProbeCount.set -> void +[SER007]StackExchange.Redis.HealthCheck.ProbeInterval.get -> System.TimeSpan +[SER007]StackExchange.Redis.HealthCheck.ProbeInterval.set -> void +[SER007]StackExchange.Redis.HealthCheck.ProbePolicy.get -> StackExchange.Redis.HealthCheck.HealthCheckProbePolicy! +[SER007]StackExchange.Redis.HealthCheck.ProbePolicy.set -> void +[SER007]StackExchange.Redis.HealthCheck.ProbeTimeout.get -> System.TimeSpan +[SER007]StackExchange.Redis.HealthCheck.ProbeTimeout.set -> void StackExchange.Redis.IServer.InventKey(StackExchange.Redis.RedisKey prefix = default(StackExchange.Redis.RedisKey)) -> StackExchange.Redis.RedisKey -static StackExchange.Redis.HealthCheck.Default.get -> StackExchange.Redis.HealthCheck! -static StackExchange.Redis.HealthCheck.HealthCheckProbe.HealthyTask.get -> System.Threading.Tasks.Task! -static StackExchange.Redis.HealthCheck.HealthCheckProbe.InconclusiveTask.get -> System.Threading.Tasks.Task! -static StackExchange.Redis.HealthCheck.HealthCheckProbe.IsConnected.get -> StackExchange.Redis.HealthCheck.HealthCheckProbe! -static StackExchange.Redis.HealthCheck.HealthCheckProbe.Ping.get -> StackExchange.Redis.HealthCheck.HealthCheckProbe! -static StackExchange.Redis.HealthCheck.HealthCheckProbe.StringSet.get -> StackExchange.Redis.HealthCheck.HealthCheckProbe! -static StackExchange.Redis.HealthCheck.HealthCheckProbe.UnhealthyTask.get -> System.Threading.Tasks.Task! -static StackExchange.Redis.HealthCheck.HealthCheckProbePolicy.AllSuccess.get -> StackExchange.Redis.HealthCheck.HealthCheckProbePolicy! -static StackExchange.Redis.HealthCheck.HealthCheckProbePolicy.AnySuccess.get -> StackExchange.Redis.HealthCheck.HealthCheckProbePolicy! -static StackExchange.Redis.HealthCheck.HealthCheckProbePolicy.MajoritySuccess.get -> StackExchange.Redis.HealthCheck.HealthCheckProbePolicy! +[SER007]static StackExchange.Redis.HealthCheck.Default.get -> StackExchange.Redis.HealthCheck! +[SER007]static StackExchange.Redis.HealthCheck.HealthCheckProbe.HealthyTask.get -> System.Threading.Tasks.Task! +[SER007]static StackExchange.Redis.HealthCheck.HealthCheckProbe.InconclusiveTask.get -> System.Threading.Tasks.Task! +[SER007]static StackExchange.Redis.HealthCheck.HealthCheckProbe.IsConnected.get -> StackExchange.Redis.HealthCheck.HealthCheckProbe! +[SER007]static StackExchange.Redis.HealthCheck.HealthCheckProbe.Ping.get -> StackExchange.Redis.HealthCheck.HealthCheckProbe! +[SER007]static StackExchange.Redis.HealthCheck.HealthCheckProbe.StringSet.get -> StackExchange.Redis.HealthCheck.HealthCheckProbe! +[SER007]static StackExchange.Redis.HealthCheck.HealthCheckProbe.UnhealthyTask.get -> System.Threading.Tasks.Task! +[SER007]static StackExchange.Redis.HealthCheck.HealthCheckProbePolicy.AllSuccess.get -> StackExchange.Redis.HealthCheck.HealthCheckProbePolicy! +[SER007]static StackExchange.Redis.HealthCheck.HealthCheckProbePolicy.AnySuccess.get -> StackExchange.Redis.HealthCheck.HealthCheckProbePolicy! +[SER007]static StackExchange.Redis.HealthCheck.HealthCheckProbePolicy.MajoritySuccess.get -> StackExchange.Redis.HealthCheck.HealthCheckProbePolicy! [SER007]override StackExchange.Redis.ConnectionGroupMember.ToString() -> string! [SER007]StackExchange.Redis.ConnectionGroupMember [SER007]StackExchange.Redis.ConnectionGroupMember.ConnectionGroupMember(StackExchange.Redis.ConfigurationOptions! configuration, string! name = "") -> void From 7c5bb1259f14c41bce190ccefac424d2b6adfd78 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 29 Jun 2026 14:59:24 +0100 Subject: [PATCH 06/33] fix all-down connection scenarios --- .../HealthCheck.StringSetProbe.cs | 2 -- src/StackExchange.Redis/MultiGroupDatabase.Async.cs | 2 +- src/StackExchange.Redis/MultiGroupDatabase.cs | 12 ++++++++++-- src/StackExchange.Redis/MultiGroupMultiplexer.cs | 3 +++ 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/StackExchange.Redis/HealthCheck.StringSetProbe.cs b/src/StackExchange.Redis/HealthCheck.StringSetProbe.cs index 256ddc0552..fb12baf4f9 100644 --- a/src/StackExchange.Redis/HealthCheck.StringSetProbe.cs +++ b/src/StackExchange.Redis/HealthCheck.StringSetProbe.cs @@ -35,7 +35,6 @@ public override async Task CheckHealthAsync(HealthCheck healt SharedRandom.NextBytes(pooled); #endif var payload = (RedisValue)pooled.AsMemory(0, LEN); - Lease? lease = null; try { // write a value to the db @@ -52,7 +51,6 @@ await database.LockTakeAsync( finally { ArrayPool.Shared.Return(pooled); - lease?.Dispose(); } } } diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Async.cs b/src/StackExchange.Redis/MultiGroupDatabase.Async.cs index 03706f3551..48f3c0187b 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.Async.cs +++ b/src/StackExchange.Redis/MultiGroupDatabase.Async.cs @@ -11,7 +11,7 @@ public Task DebugObjectAsync(RedisKey key, CommandFlags flags = Comm => GetActiveDatabase().DebugObjectAsync(key, flags); public Task IdentifyEndpointAsync(RedisKey key = default, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().IdentifyEndpointAsync(key, flags); + => TryGetActiveDatabase()?.IdentifyEndpointAsync(key, flags) ?? NoEndpoint; public Task KeyMigrateAsync(RedisKey key, EndPoint toServer, int toDatabase = 0, int timeoutMilliseconds = 0, MigrateOptions migrateOptions = MigrateOptions.None, CommandFlags flags = CommandFlags.None) => GetActiveDatabase().KeyMigrateAsync(key, toServer, toDatabase, timeoutMilliseconds, migrateOptions, flags); diff --git a/src/StackExchange.Redis/MultiGroupDatabase.cs b/src/StackExchange.Redis/MultiGroupDatabase.cs index b4248c2dc4..7bd6265323 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.cs +++ b/src/StackExchange.Redis/MultiGroupDatabase.cs @@ -13,6 +13,14 @@ internal sealed partial class MultiGroupDatabase(MultiGroupMultiplexer parent, i // for high DB numbers this might allocate even for null async-state scenarios; unavoidable for now private IDatabase GetActiveDatabase() => parent.Active.GetDatabase(database, asyncState); + // non-throwing twin of GetActiveDatabase: returns null when the group currently has no active + // member, for use by members that have an obvious trivial result when disconnected + private IDatabase? TryGetActiveDatabase() => parent.TryGetActive()?.GetDatabase(database, asyncState); + + // a completed "no endpoint" result, reused for IdentifyEndpointAsync when the group is fully down + internal static readonly System.Threading.Tasks.Task NoEndpoint + = System.Threading.Tasks.Task.FromResult(null); + // Core methods public IBatch CreateBatch(object? asyncState = null) => GetActiveDatabase().CreateBatch(asyncState); @@ -27,10 +35,10 @@ public RedisValue DebugObject(RedisKey key, CommandFlags flags = CommandFlags.No => GetActiveDatabase().DebugObject(key, flags); public System.Net.EndPoint? IdentifyEndpoint(RedisKey key = default, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().IdentifyEndpoint(key, flags); + => TryGetActiveDatabase()?.IdentifyEndpoint(key, flags); public bool IsConnected(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().IsConnected(key, flags); + => TryGetActiveDatabase()?.IsConnected(key, flags) ?? false; public System.TimeSpan Ping(CommandFlags flags = CommandFlags.None) => GetActiveDatabase().Ping(flags); diff --git a/src/StackExchange.Redis/MultiGroupMultiplexer.cs b/src/StackExchange.Redis/MultiGroupMultiplexer.cs index ecc7883daa..00806e2b2a 100644 --- a/src/StackExchange.Redis/MultiGroupMultiplexer.cs +++ b/src/StackExchange.Redis/MultiGroupMultiplexer.cs @@ -255,6 +255,9 @@ static ConnectionMultiplexer Throw() => } } + // non-throwing twin of Active, for callers that have a trivial answer when the group is fully down + internal ConnectionMultiplexer? TryGetActive() => _active; + private ConnectionGroupMember? GetActiveMember() { var active = _active; From 5bfb55696d5d29bc54de442d5a627fa976930949 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 29 Jun 2026 15:15:25 +0100 Subject: [PATCH 07/33] non-connected fix for subscriber --- src/StackExchange.Redis/MultiGroupDatabase.Async.cs | 2 +- src/StackExchange.Redis/MultiGroupDatabase.cs | 4 ---- src/StackExchange.Redis/MultiGroupMultiplexer.cs | 3 +++ src/StackExchange.Redis/MultiGroupSubscriber.cs | 12 ++++++++---- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Async.cs b/src/StackExchange.Redis/MultiGroupDatabase.Async.cs index 48f3c0187b..b1e70bf34e 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.Async.cs +++ b/src/StackExchange.Redis/MultiGroupDatabase.Async.cs @@ -11,7 +11,7 @@ public Task DebugObjectAsync(RedisKey key, CommandFlags flags = Comm => GetActiveDatabase().DebugObjectAsync(key, flags); public Task IdentifyEndpointAsync(RedisKey key = default, CommandFlags flags = CommandFlags.None) - => TryGetActiveDatabase()?.IdentifyEndpointAsync(key, flags) ?? NoEndpoint; + => TryGetActiveDatabase()?.IdentifyEndpointAsync(key, flags) ?? MultiGroupMultiplexer.NoEndpoint; public Task KeyMigrateAsync(RedisKey key, EndPoint toServer, int toDatabase = 0, int timeoutMilliseconds = 0, MigrateOptions migrateOptions = MigrateOptions.None, CommandFlags flags = CommandFlags.None) => GetActiveDatabase().KeyMigrateAsync(key, toServer, toDatabase, timeoutMilliseconds, migrateOptions, flags); diff --git a/src/StackExchange.Redis/MultiGroupDatabase.cs b/src/StackExchange.Redis/MultiGroupDatabase.cs index 7bd6265323..64728438f8 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.cs +++ b/src/StackExchange.Redis/MultiGroupDatabase.cs @@ -17,10 +17,6 @@ internal sealed partial class MultiGroupDatabase(MultiGroupMultiplexer parent, i // member, for use by members that have an obvious trivial result when disconnected private IDatabase? TryGetActiveDatabase() => parent.TryGetActive()?.GetDatabase(database, asyncState); - // a completed "no endpoint" result, reused for IdentifyEndpointAsync when the group is fully down - internal static readonly System.Threading.Tasks.Task NoEndpoint - = System.Threading.Tasks.Task.FromResult(null); - // Core methods public IBatch CreateBatch(object? asyncState = null) => GetActiveDatabase().CreateBatch(asyncState); diff --git a/src/StackExchange.Redis/MultiGroupMultiplexer.cs b/src/StackExchange.Redis/MultiGroupMultiplexer.cs index 00806e2b2a..9c2e1d45bc 100644 --- a/src/StackExchange.Redis/MultiGroupMultiplexer.cs +++ b/src/StackExchange.Redis/MultiGroupMultiplexer.cs @@ -258,6 +258,9 @@ static ConnectionMultiplexer Throw() => // non-throwing twin of Active, for callers that have a trivial answer when the group is fully down internal ConnectionMultiplexer? TryGetActive() => _active; + // a completed "no endpoint" result, shared by the database/subscriber facades when the group is fully down + internal static readonly Task NoEndpoint = Task.FromResult(null); + private ConnectionGroupMember? GetActiveMember() { var active = _active; diff --git a/src/StackExchange.Redis/MultiGroupSubscriber.cs b/src/StackExchange.Redis/MultiGroupSubscriber.cs index 4b62d58905..3ce12764de 100644 --- a/src/StackExchange.Redis/MultiGroupSubscriber.cs +++ b/src/StackExchange.Redis/MultiGroupSubscriber.cs @@ -9,6 +9,10 @@ internal sealed partial class MultiGroupSubscriber(MultiGroupMultiplexer parent, // for a lot of things, we can defer through to the active implementation private ISubscriber GetActiveSubscriber() => parent.Active.GetSubscriber(asyncState); + // non-throwing twin of GetActiveSubscriber: returns null when the group currently has no active + // member, for use by members that have an obvious trivial result when disconnected + private ISubscriber? TryGetActiveSubscriber() => parent.TryGetActive()?.GetSubscriber(asyncState); + public IConnectionMultiplexer Multiplexer => parent; public bool TryWait(Task task) => GetActiveSubscriber().TryWait(task); @@ -24,12 +28,12 @@ internal sealed partial class MultiGroupSubscriber(MultiGroupMultiplexer parent, public Task PingAsync(CommandFlags flags = CommandFlags.None) => GetActiveSubscriber().PingAsync(flags); public EndPoint? IdentifyEndpoint(RedisChannel channel, CommandFlags flags = CommandFlags.None) => - GetActiveSubscriber().IdentifyEndpoint(channel, flags); + TryGetActiveSubscriber()?.IdentifyEndpoint(channel, flags); public Task IdentifyEndpointAsync(RedisChannel channel, CommandFlags flags = CommandFlags.None) => - GetActiveSubscriber().IdentifyEndpointAsync(channel, flags); + TryGetActiveSubscriber()?.IdentifyEndpointAsync(channel, flags) ?? MultiGroupMultiplexer.NoEndpoint; - public bool IsConnected(RedisChannel channel = default) => GetActiveSubscriber().IsConnected(); + public bool IsConnected(RedisChannel channel = default) => TryGetActiveSubscriber()?.IsConnected() ?? false; public long Publish(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) => GetActiveSubscriber().Publish(channel, message, flags); @@ -37,5 +41,5 @@ public long Publish(RedisChannel channel, RedisValue message, CommandFlags flags public Task PublishAsync(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) => GetActiveSubscriber().PublishAsync(channel, message, flags); - public EndPoint? SubscribedEndpoint(RedisChannel channel) => GetActiveSubscriber().SubscribedEndpoint(channel); + public EndPoint? SubscribedEndpoint(RedisChannel channel) => TryGetActiveSubscriber()?.SubscribedEndpoint(channel); } From 972f9dbc9d51b31e3aa2699fd4f221326434088a Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 10 Jul 2026 10:39:17 +0100 Subject: [PATCH 08/33] Circuit breaker (#3126) * move things around and write core * bucket logic * buildable * nit * add unit tests * end-to-end tests * docs * improve circuit-breaker reactivity * gate health-checks, since it can now run concurrently due to circuit-breaker * end-to-end tests --- docs/ActiveActive.md | 135 +- .../Availability/CircuitBreaker.cs | 451 ++++++ .../HealthCheck.ConnectedProbe.cs | 3 +- .../{ => Availability}/HealthCheck.Execute.cs | 4 +- .../HealthCheck.HealthCheckProbe.cs | 3 +- .../HealthCheck.HealthCheckProbeContext.cs | 3 +- .../HealthCheck.HealthCheckProbePolicy.cs | 2 +- .../HealthCheck.PingProbe.cs | 3 +- .../HealthCheck.StringSetProbe.cs | 2 +- .../{ => Availability}/HealthCheck.cs | 2 +- .../MultiGroupDatabase.Arrays.Async.cs | 2 +- .../MultiGroupDatabase.Arrays.cs | 2 +- .../MultiGroupDatabase.Async.cs | 2 +- .../MultiGroupDatabase.Geo.Async.cs | 2 +- .../MultiGroupDatabase.Geo.cs | 2 +- .../MultiGroupDatabase.Hashes.Async.cs | 2 +- .../MultiGroupDatabase.Hashes.cs | 2 +- .../MultiGroupDatabase.HyperLogLog.Async.cs | 2 +- .../MultiGroupDatabase.HyperLogLog.cs | 2 +- .../MultiGroupDatabase.Keys.Async.cs | 2 +- .../MultiGroupDatabase.Keys.cs | 2 +- .../MultiGroupDatabase.Lists.Async.cs | 2 +- .../MultiGroupDatabase.Lists.cs | 2 +- .../MultiGroupDatabase.Sets.Async.cs | 2 +- .../MultiGroupDatabase.Sets.cs | 2 +- .../MultiGroupDatabase.SortedSets.Async.cs | 2 +- .../MultiGroupDatabase.SortedSets.cs | 4 +- .../MultiGroupDatabase.Streams.Async.cs | 2 +- .../MultiGroupDatabase.Streams.cs | 2 +- .../MultiGroupDatabase.Strings.Async.cs | 2 +- .../MultiGroupDatabase.Strings.cs | 2 +- .../MultiGroupDatabase.VectorSets.Async.cs | 2 +- .../MultiGroupDatabase.VectorSets.cs | 2 +- .../{ => Availability}/MultiGroupDatabase.cs | 2 +- .../Availability/MultiGroupMultiplexer.cs | 1230 +++++++++++++++++ .../{ => Availability}/MultiGroupOptions.cs | 11 +- .../MultiGroupSubscriber.Tracking.cs | 2 +- .../MultiGroupSubscriber.cs | 2 +- .../ConfigurationOptions.cs | 8 + .../Enums/ConnectionFailureType.cs | 11 +- .../Interfaces/IConnectionGroup.cs | 3 +- src/StackExchange.Redis/Message.cs | 15 +- .../MultiGroupMultiplexer.cs | 1163 ---------------- src/StackExchange.Redis/PhysicalBridge.cs | 28 +- .../PhysicalConnection.Read.cs | 10 +- src/StackExchange.Redis/PhysicalConnection.cs | 78 +- .../PublicAPI/PublicAPI.Unshipped.txt | 240 ++-- .../PublicAPI/net6.0/PublicAPI.Unshipped.txt | 6 + src/StackExchange.Redis/RedisBatch.cs | 6 +- src/StackExchange.Redis/RedisTransaction.cs | 21 +- src/StackExchange.Redis/ResultBox.cs | 3 + src/StackExchange.Redis/ServerEndPoint.cs | 1 + .../StackExchange.Redis.csproj | 7 +- .../ActiveActiveIntegrationTests.cs | 1 + .../CircuitBreakerServerTests.cs | 83 ++ .../CircuitBreakerUnitTests.cs | 239 ++++ .../StackExchange.Redis.Tests/ConfigTests.cs | 1 + .../HealthCheckPolicyUnitTests.cs | 2 +- .../MultiGroupTests/BasicMultiGroupTests.cs | 1 + .../CircuitBreakerRerouteTests.cs | 132 ++ .../ResultBoxTests.cs | 4 +- 61 files changed, 2616 insertions(+), 1350 deletions(-) create mode 100644 src/StackExchange.Redis/Availability/CircuitBreaker.cs rename src/StackExchange.Redis/{ => Availability}/HealthCheck.ConnectedProbe.cs (93%) rename src/StackExchange.Redis/{ => Availability}/HealthCheck.Execute.cs (99%) rename src/StackExchange.Redis/{ => Availability}/HealthCheck.HealthCheckProbe.cs (98%) rename src/StackExchange.Redis/{ => Availability}/HealthCheck.HealthCheckProbeContext.cs (95%) rename src/StackExchange.Redis/{ => Availability}/HealthCheck.HealthCheckProbePolicy.cs (98%) rename src/StackExchange.Redis/{ => Availability}/HealthCheck.PingProbe.cs (93%) rename src/StackExchange.Redis/{ => Availability}/HealthCheck.StringSetProbe.cs (97%) rename src/StackExchange.Redis/{ => Availability}/HealthCheck.cs (98%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.Arrays.Async.cs (99%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.Arrays.cs (99%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.Async.cs (99%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.Geo.Async.cs (98%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.Geo.cs (98%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.Hashes.Async.cs (99%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.Hashes.cs (99%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.HyperLogLog.Async.cs (96%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.HyperLogLog.cs (96%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.Keys.Async.cs (98%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.Keys.cs (98%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.Lists.Async.cs (99%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.Lists.cs (99%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.Sets.Async.cs (98%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.Sets.cs (98%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.SortedSets.Async.cs (99%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.SortedSets.cs (99%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.Streams.Async.cs (99%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.Streams.cs (99%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.Strings.Async.cs (99%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.Strings.cs (99%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.VectorSets.Async.cs (98%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.VectorSets.cs (98%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupDatabase.cs (99%) create mode 100644 src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs rename src/StackExchange.Redis/{ => Availability}/MultiGroupOptions.cs (81%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupSubscriber.Tracking.cs (99%) rename src/StackExchange.Redis/{ => Availability}/MultiGroupSubscriber.cs (98%) delete mode 100644 src/StackExchange.Redis/MultiGroupMultiplexer.cs create mode 100644 src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Unshipped.txt create mode 100644 tests/StackExchange.Redis.Tests/CircuitBreakerServerTests.cs create mode 100644 tests/StackExchange.Redis.Tests/CircuitBreakerUnitTests.cs create mode 100644 tests/StackExchange.Redis.Tests/MultiGroupTests/CircuitBreakerRerouteTests.cs diff --git a/docs/ActiveActive.md b/docs/ActiveActive.md index 1217f35fad..e49bbf88a2 100644 --- a/docs/ActiveActive.md +++ b/docs/ActiveActive.md @@ -217,7 +217,7 @@ var healthCheck = new HealthCheck var options = new MultiGroupOptions { - DefaultHealthCheck = healthCheck + HealthCheck = healthCheck }; ConnectionGroupMember[] members = [ @@ -239,7 +239,7 @@ await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members); // Equivalent to: var options = new MultiGroupOptions { - DefaultHealthCheck = HealthCheck.Default + HealthCheck = HealthCheck.Default }; await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members, options); ``` @@ -253,7 +253,7 @@ customHealthCheck.ProbeCount = 5; var options = new MultiGroupOptions { - DefaultHealthCheck = customHealthCheck + HealthCheck = customHealthCheck }; ``` @@ -434,7 +434,7 @@ var healthCheck = new HealthCheck var options = new MultiGroupOptions { - DefaultHealthCheck = healthCheck + HealthCheck = healthCheck }; ``` @@ -457,6 +457,133 @@ When a health check fails for a member: 5. **Set reasonable timeouts**: Ensure `ProbeTimeout` accounts for network latency to your Redis servers 6. **Consider replica behavior**: Write-based probes automatically skip replicas to avoid false negatives +## Circuit Breakers + +Where health checks *actively* probe each member on a timer, a **circuit breaker** works *passively*: it observes the outcome of the normal traffic already flowing over a connection, and tears that connection down as soon as it decides the connection has become unstable. The two are complementary: + +- A **health check** answers *"is this member reachable?"* by sending its own probes every `Interval`. +- A **circuit breaker** answers *"is the traffic I'm already sending actually succeeding?"* with no extra round-trips, reacting the instant real commands start failing. + +When a circuit breaker trips, it shuts the underlying physical connection down with `ConnectionFailureType.CircuitBreaker`. The connection then reconnects as usual, and — in an Active:Active group — the health check and member-selection logic route traffic to other members until the affected member recovers. A circuit breaker is evaluated **per connection**, so +its state is scoped to exactly the connection whose health it is measuring; a reconnect starts from a clean slate - but breaking the connection is sufficient for Active:Active to notice non-availability. + +### Configuring a Circuit Breaker for a Group + +Circuit breakers are configured globally for all members via `MultiGroupOptions`, alongside the health check. The setting flows into every member connection: + +```csharp +var options = new MultiGroupOptions +{ + CircuitBreaker = CircuitBreaker.Default +}; + +ConnectionGroupMember[] members = [ + new("us-east.redis.example.com:6379", name: "US East") { Weight = 10 }, + new("us-west.redis.example.com:6379", name: "US West") { Weight = 5 } +]; + +await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members, options); +``` + +If you don't specify one, `CircuitBreaker.Default` is used automatically: + +```csharp +// these are equivalent +await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members); + +var options = new MultiGroupOptions { CircuitBreaker = CircuitBreaker.Default }; +await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members, options); +``` + +### Tuning the Default Circuit Breaker + +The default circuit breaker uses a rolling time-window: it counts successes and failures over a short window and trips once the failure rate crosses a +threshold, provided enough failures have been seen to be statistically meaningful. Use `CircuitBreaker.Builder` to tune it: + +```csharp +var options = new MultiGroupOptions +{ + CircuitBreaker = new CircuitBreaker.Builder + { + FailureRateThreshold = 25, // trip above 25% failures + MinimumNumberOfFailures = 100, // ...but only after 100 failures in the window + MetricsWindowSize = TimeSpan.FromSeconds(5), // rolling window to measure over + TrackedExceptions = [typeof(RedisTimeoutException)], // which faults count as failures + } +}; +``` + +A `Builder` converts implicitly to a `CircuitBreaker`, so it can be assigned directly as above; calling `.Create()` explicitly is equivalent. + +| Property | Default | Description | +|----------|---------|-------------| +| `FailureRateThreshold` | 10 | Percentage of failures within the window that trips the breaker | +| `MinimumNumberOfFailures` | 1000 | Minimum tracked failures in the window before the breaker can trip (avoids acting on tiny samples) | +| `MetricsWindowSize` | 2 seconds | Rolling window over which successes and failures are counted | +| `TrackedExceptions` | `null` | Exception types that count as failures; when `null`, `RedisConnectionException` and `RedisTimeoutException` are tracked | + +`TrackedExceptions` controls which faults count against the breaker; anything not tracked is treated as a success for circuit-breaking purposes (for example, a `RedisServerException` for a bad command is not a *connection* problem). Passing an array containing `typeof(Exception)` tracks everything; passing an empty array tracks nothing. + +### Disabling the Circuit Breaker + +Use `CircuitBreaker.None` to opt out entirely: + +```csharp +var options = new MultiGroupOptions +{ + CircuitBreaker = CircuitBreaker.None +}; +``` + +### Custom Circuit Breakers (Advanced) + +> This is an advanced extension point; most applications should use `CircuitBreaker.Default` (optionally tuned via `CircuitBreaker.Builder`) or `CircuitBreaker.None`. + +You can implement your own policy by extending `CircuitBreaker` and its `Accumulator`. `CreateAccumulator()` is called once per underlying connection, so each accumulator holds the state for a single connection. +For every completed message the connection calls `ObserveResult`, passing a `CircuitBreakerContext` that exposes whether the operation succeeded (`Success`) and the associated `Fault` (if any). +Return `true` from `IsHealthy()` while the connection should be considered **healthy**, or `false` to **trip** the breaker and tear the connection down: + +```csharp +public sealed class ConsecutiveFailureBreaker(int limit) : CircuitBreaker +{ + public override Accumulator CreateAccumulator() => new Acc(limit); + + private sealed class Acc(int limit) : Accumulator + { + private int _consecutiveFailures; + + public override bool ObserveResult(in CircuitBreakerContext context) + { + if (context.Success) + { + _consecutiveFailures = 0; + } + else + { + _consecutiveFailures++; + } + + // if Evaluate is specified, we should also compute IsHealthy(), + // often this can reuse local state needed by ObserveResult + return context.Evaluate ? IsHealthy() : true; + } + + // healthy until we hit the configured run of consecutive failures + public override bool IsHealthy() => _consecutiveFailures < limit; + + // called if the connection wants to discard accumulated history + public override void Reset() => _consecutiveFailures = 0; + } +} + +var options = new MultiGroupOptions +{ + CircuitBreaker = new ConsecutiveFailureBreaker(limit: 5) +}; +``` + +Keep `ObserveResult` cheap and thread-safe: it runs on the hot path for every completed message, and may be called concurrently. + ## Manual Failover In some scenarios, you may need to manually control which member is actively serving traffic, overriding the automatic selection based on weight and latency. The `TryFailoverTo` method allows you to explicitly switch to a specific member or restore automatic selection. diff --git a/src/StackExchange.Redis/Availability/CircuitBreaker.cs b/src/StackExchange.Redis/Availability/CircuitBreaker.cs new file mode 100644 index 0000000000..feaa653993 --- /dev/null +++ b/src/StackExchange.Redis/Availability/CircuitBreaker.cs @@ -0,0 +1,451 @@ +using System; +#if !NET8_0_OR_GREATER +using System.Diagnostics; +#endif +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Threading; +using RESPite; + +namespace StackExchange.Redis.Availability; + +/// +/// Reports connection health by responding to observed success and failure conditions of processed messages. +/// +[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +public abstract class CircuitBreaker +{ + /// + /// Default circuit-breaker logic. + /// + public static CircuitBreaker Default => Builder.DefaultInstance; + + /// + /// No circuit-breaker logic is applied. + /// + public static CircuitBreaker None => NulCircuitBreaker.Instance; + + /// + /// Allows configuration of the default implementation. + /// + public class Builder + { + private const double DefaultFailureRateThreshold = 10; + private const int DefaultMinimumNumberOfFailures = 1000; + private static readonly TimeSpan DefaultMetricsWindowSize = TimeSpan.FromSeconds(2); + + internal static CircuitBreaker DefaultInstance = new DefaultCircuitBreaker( + DefaultFailureRateThreshold, + DefaultMinimumNumberOfFailures, + DefaultMetricsWindowSize, +#if NET8_0_OR_GREATER + timeProvider: null, +#endif + trackedExceptions: null); + + /// + /// Percentage of failures to trigger circuit breaker. + /// + /// Failures are only included if they are of tracked exception types. + public double FailureRateThreshold { get; set; } = DefaultFailureRateThreshold; + + /// + /// Minimum failures before circuit breaker can open. + /// + public int MinimumNumberOfFailures { get; set; } = DefaultMinimumNumberOfFailures; + + /// + /// Time window for collecting metrics. + /// + public TimeSpan MetricsWindowSize { get; set; } = DefaultMetricsWindowSize; + +#if NET8_0_OR_GREATER + /// + /// Time source used to drive the metrics window; when null, the system clock is used. + /// Intended for testing, to make the time-windowed logic deterministic. + /// + internal TimeProvider? TimeProvider { get; set; } +#endif + + /// + /// Create a new circuit-breaker instance. + /// + public CircuitBreaker Create() + { + if ((FailureRateThreshold is DefaultFailureRateThreshold + & MinimumNumberOfFailures is DefaultMinimumNumberOfFailures +#if NET8_0_OR_GREATER + & TimeProvider is null +#endif + & TrackedExceptions is null) + && MetricsWindowSize == DefaultMetricsWindowSize) + return DefaultInstance; + + return new DefaultCircuitBreaker( + FailureRateThreshold, + MinimumNumberOfFailures, + MetricsWindowSize, +#if NET8_0_OR_GREATER + TimeProvider, +#endif + TrackedExceptions); + } + + /// + /// Create a new circuit-breaker instance. + /// + public static implicit operator CircuitBreaker(Builder builder) => builder.Create(); + + /// + /// Exceptions that count as failures. When null, + /// and are assumed. + /// + public Type[]? TrackedExceptions { get; set; } + } + + /// + /// Create an object to collate observations for a connection. + /// + public abstract Accumulator CreateAccumulator(); + + /// + /// Collates observations for a connection. + /// + public abstract class Accumulator + { + /// + /// Record a message outcome, and indicate whether the connection is considered healthy. + /// + /// True if the connection is still considered healthy. + /// struct arg here is in case we want to add more things later + public abstract bool ObserveResult(in CircuitBreakerContext context); + + internal bool ObserveResult(Exception? fault) + { + // only evaluate state upon failure; don't pay that overhead for success, just increment the counters + bool evaluate = fault is not null; + var ctx = new CircuitBreakerContext(fault, evaluate: evaluate); + bool healthy = ObserveResult(in ctx); + + // when we didn't ask for an evaluation, the returned verdict is meaningless: a custom + // implementation might return default(false) without having actually computed anything. + // never treat a non-evaluating observation as unhealthy - only a genuine evaluation may trip. + return !evaluate || healthy; + } + + /// + /// Indicate whether the connection is currently considered healthy, without recording an observation. + /// + /// True if the connection is considered healthy. + public abstract bool IsHealthy(); + + /// + /// Discard all accumulated observations, returning to a clean state. + /// + public abstract void Reset(); + } + + /// + /// Provides information about a circuit-breaker test. + /// + public readonly struct CircuitBreakerContext(Exception? fault, bool evaluate = true) + { + /// + /// Was the operation a success. + /// + [MemberNotNullWhen(false, nameof(Fault))] + public bool Success => fault is null; + + /// + /// The fault associated with the operation. + /// + public Exception? Fault => fault; + + internal bool Evaluate => evaluate; + } + + private enum ExceptionStrategy + { + Default, + Any, + None, + CustomOpen, + CustomSealed, + } + + private sealed class NulCircuitBreaker : CircuitBreaker + { + public static readonly NulCircuitBreaker Instance = new(); + private NulCircuitBreaker() { } + public override Accumulator CreateAccumulator() => NulAccumulator.AccumulatorInstance; + + private sealed class NulAccumulator : Accumulator + { + public static readonly NulAccumulator AccumulatorInstance = new(); + private NulAccumulator() { } + public override bool ObserveResult(in CircuitBreakerContext context) => true; + public override bool IsHealthy() => true; + public override void Reset() { } + } + } + private sealed class DefaultCircuitBreaker : CircuitBreaker + { + private readonly ExceptionStrategy _trackingStrategy; + private readonly Type[] _trackedExceptions; + private readonly double _failureRateThreshold; + private readonly int _minimumNumberOfFailures; + + // the metrics window is divided into a fixed number of equal time-slices ("buckets"); + // this lets us keep a rolling count cheaply, evicting whole buckets as they age out, + // rather than storing (and pruning) a timestamp per event + private const int BucketCount = 10; + private readonly long _bucketTicks; // width of one bucket, in high-resolution ticks + +#if NET8_0_OR_GREATER + private readonly TimeProvider _time; +#endif + + // which time-slice ("epoch") are we in right now: the high-resolution timestamp (from + // TimeProvider where available, so tests can drive it; otherwise the Stopwatch clock) + // divided down to bucket width + private long GetEpoch() => +#if NET8_0_OR_GREATER + _time.GetTimestamp() +#else + Stopwatch.GetTimestamp() +#endif + / _bucketTicks; + + public DefaultCircuitBreaker( + double failureRateThreshold, + int minimumNumberOfFailures, + TimeSpan metricsWindowSize, +#if NET8_0_OR_GREATER + TimeProvider? timeProvider, +#endif + Type[]? trackedExceptions) + { + _trackedExceptions = CheckExceptions(trackedExceptions, out _trackingStrategy); + _failureRateThreshold = failureRateThreshold; + _minimumNumberOfFailures = minimumNumberOfFailures; + +#if NET8_0_OR_GREATER + _time = timeProvider ?? TimeProvider.System; + long frequency = _time.TimestampFrequency; +#else + long frequency = Stopwatch.Frequency; +#endif + long windowTicks = (long)(metricsWindowSize.TotalSeconds * frequency); + _bucketTicks = Math.Max(1, windowTicks / BucketCount); + } + + public override Accumulator CreateAccumulator() => new DefaultAccumulator(this); + + private sealed class DefaultAccumulator(DefaultCircuitBreaker breaker) : Accumulator + { + // ring of buckets; each holds the counts for a single time-slice, tagged with the + // slice ("epoch") it currently represents so we can tell live buckets from stale ones +#if NET8_0_OR_GREATER + // inline it directly into the accumulator + private BucketRing _buckets; // cannot be "readonly", else the indexer is "ref readonly" + [InlineArray(BucketCount)] + private struct BucketRing + { + // beware init rules; we're OK in this case because _buckets is in a field on a heap object, + // but if BucketRing was ever used as a local: [SkipLocalsInit] rules can apply. + private Bucket _element0; + } +#else + // fallback to a separate heap array + private readonly Bucket[] _buckets = new Bucket[BucketCount]; +#endif + + private struct Bucket + { + private long _epoch; + private volatile int _success, _failure; + + // note that Volatile guarantees atomicity (even on x86), so no torn values here + // see https://learn.microsoft.com/dotnet/api/system.threading.volatile + public long Epoch => Volatile.Read(ref _epoch); + + public int Success => _success; + public int Failure => _failure; + + public void Count(long epoch, bool success) + { + if (epoch != Epoch) + { + // epoch rollover; clear the counts first, to prevent anyone over-counting + // in their count loop (under-counting is fine); dropped counts are self-correcting + // if there's an actual problem, stale data misread as current: is not. + _success = _failure = 0; + Volatile.Write(ref _epoch, epoch); + + // if we want to get *super* accurate, we could use bit-packing here and use + // CAS over a 64-bit value, but we'd need to compromise on count upper bounds + // *and* we'd need to consider the max epoch problem - maybe 32 bits for epoch + // and 16 bits for each count, but... let's keep things simple and accept a + // few dropped counts instead, and luxuriate in unreasonably fat epochs and counts. + } + // ReSharper disable ByRefArgumentIsVolatileField + Interlocked.Increment(ref success ? ref _success : ref _failure); + // ReSharper restore ByRefArgumentIsVolatileField + } + + public void Reset() + { + // clear the counts first (as in Count), so a concurrent count-loop reader never + // attributes these stale counts to the epoch; then blank the epoch back to "unused" + _success = _failure = 0; + Volatile.Write(ref _epoch, 0); + } + } + + public override bool ObserveResult(in CircuitBreakerContext context) + { + // not-tracked failures (based on exception type) count as "success" for the purposes of circuit breaking + bool countAsSuccess = context.Success || !breaker.IsTracked(context.Fault); + + // which time-slice are we in, and where does it live in the ring? + long epoch = breaker.GetEpoch(); + int index = (int)(epoch % BucketCount); + + // note: to avoid concurrency problems, we're going lock-free here; *technically* this + // might mean we see race oddities during epoch rollovers, but in general that means we're + // miscounting by a tiny amount during intervals when there's enough load to get a race, + // in which case: we're probably fine; also, note that when tracking by endpoint, we're + // only getting results one at a time, so we shouldn't be over-stomping much *anyway* + Span buckets = _buckets; // *not* a payload copy; this is in-place over the data + ref Bucket bucket = ref buckets[index]; + bucket.Count(epoch, countAsSuccess); + + return !context.Evaluate || Evaluate(epoch); + } + + public override bool IsHealthy() => Evaluate(breaker.GetEpoch()); + + // evaluate health from the buckets still inside the window ending at the given epoch, + // without recording anything; shared by ObserveResult and IsHealthy + private bool Evaluate(long epoch) + { + // sum only the buckets still inside the window; anything older is ignored + // (and contributes nothing even if left un-recycled). empty/never-used buckets + // add zero, so no explicit "unused" sentinel is required + long oldest = epoch - BucketCount + 1; + int failures, total = failures = 0; + Span buckets = _buckets; // *not* a payload copy; this is in-place over the data + foreach (ref readonly Bucket b in buckets) + { + if (b.Epoch < oldest) continue; + + int failure; + failures += failure = b.Failure; // capture to avoid double-read (think epoch rollover) + total += b.Success + failure; + } + + // don't act until we've seen enough failures to be statistically meaningful + if (total is 0 | failures < breaker._minimumNumberOfFailures) + { + return true; + } + double failureRate = (failures * 100d) / total; + return failureRate < breaker._failureRateThreshold; + } + + public override void Reset() + { + Span buckets = _buckets; // in-place over the data, not a copy + foreach (ref Bucket b in buckets) + { + b.Reset(); + } + } + } + + private static Type[] CheckExceptions(Type[]? tracked, out ExceptionStrategy strategy) + { + if (tracked is null) + { + strategy = ExceptionStrategy.Default; + return []; + } + if (tracked.Length is 0) + { + strategy = ExceptionStrategy.None; + return []; + } + strategy = ExceptionStrategy.CustomOpen; + + static bool Contains(Type[] array, Type type) + { + for (int i = 0; i < array.Length; i++) + { + if (array[i] == type) return true; + } + + return false; + } + + // if we have Exception anywhere: we'll track everything + if (Contains(tracked, typeof(Exception))) + { + strategy = ExceptionStrategy.Any; + return []; + } + + if (tracked.Length is 2 + && Contains(tracked, typeof(RedisConnectionException)) + && Contains(tracked, typeof(RedisTimeoutException))) + { + strategy = ExceptionStrategy.Default; + return []; + } + + // finally, see if they're all sealed types + strategy = ExceptionStrategy.CustomSealed; + foreach (var exception in tracked) + { + if (!exception.IsSealed) + { + strategy = ExceptionStrategy.CustomOpen; + break; + } + } + return tracked; + } + + private bool IsTracked(Exception? fault) + { + if (fault is null) return false; + switch (_trackingStrategy) + { + case ExceptionStrategy.Any: + return true; + case ExceptionStrategy.Default: + return fault is RedisTimeoutException or RedisConnectionException; + case ExceptionStrategy.None: + return false; + } + + var span = _trackedExceptions.AsSpan(); + var actualType = fault.GetType(); + // check for exact matches + foreach (var testType in span) + { + if (ReferenceEquals(testType, actualType)) return true; + } + + if (_trackingStrategy is ExceptionStrategy.CustomOpen) + { + // we need to check for subclasses (more expensive) + foreach (var testType in span) + { + if (!testType.IsSealed && testType.IsAssignableFrom(actualType)) return true; + } + } + + return false; + } + } +} diff --git a/src/StackExchange.Redis/HealthCheck.ConnectedProbe.cs b/src/StackExchange.Redis/Availability/HealthCheck.ConnectedProbe.cs similarity index 93% rename from src/StackExchange.Redis/HealthCheck.ConnectedProbe.cs rename to src/StackExchange.Redis/Availability/HealthCheck.ConnectedProbe.cs index 0a669324b2..3850aaa61c 100644 --- a/src/StackExchange.Redis/HealthCheck.ConnectedProbe.cs +++ b/src/StackExchange.Redis/Availability/HealthCheck.ConnectedProbe.cs @@ -1,7 +1,6 @@ -using System.Net; using System.Threading.Tasks; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; public sealed partial class HealthCheck { diff --git a/src/StackExchange.Redis/HealthCheck.Execute.cs b/src/StackExchange.Redis/Availability/HealthCheck.Execute.cs similarity index 99% rename from src/StackExchange.Redis/HealthCheck.Execute.cs rename to src/StackExchange.Redis/Availability/HealthCheck.Execute.cs index 29070233bd..968b4e81e6 100644 --- a/src/StackExchange.Redis/HealthCheck.Execute.cs +++ b/src/StackExchange.Redis/Availability/HealthCheck.Execute.cs @@ -1,11 +1,9 @@ using System; -using System.Buffers; using System.Diagnostics; -using System.Net; using System.Threading; using System.Threading.Tasks; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; public sealed partial class HealthCheck { diff --git a/src/StackExchange.Redis/HealthCheck.HealthCheckProbe.cs b/src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbe.cs similarity index 98% rename from src/StackExchange.Redis/HealthCheck.HealthCheckProbe.cs rename to src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbe.cs index 717863150d..6789bc8910 100644 --- a/src/StackExchange.Redis/HealthCheck.HealthCheckProbe.cs +++ b/src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbe.cs @@ -1,10 +1,9 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Net; using System.Threading.Tasks; using RESPite; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; public sealed partial class HealthCheck { diff --git a/src/StackExchange.Redis/HealthCheck.HealthCheckProbeContext.cs b/src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbeContext.cs similarity index 95% rename from src/StackExchange.Redis/HealthCheck.HealthCheckProbeContext.cs rename to src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbeContext.cs index a0e6a58a35..82062e830c 100644 --- a/src/StackExchange.Redis/HealthCheck.HealthCheckProbeContext.cs +++ b/src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbeContext.cs @@ -1,9 +1,8 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; using RESPite; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; public sealed partial class HealthCheck { diff --git a/src/StackExchange.Redis/HealthCheck.HealthCheckProbePolicy.cs b/src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbePolicy.cs similarity index 98% rename from src/StackExchange.Redis/HealthCheck.HealthCheckProbePolicy.cs rename to src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbePolicy.cs index c303bf8edc..5e465dae2b 100644 --- a/src/StackExchange.Redis/HealthCheck.HealthCheckProbePolicy.cs +++ b/src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbePolicy.cs @@ -2,7 +2,7 @@ using System.Diagnostics.CodeAnalysis; using RESPite; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; public sealed partial class HealthCheck { diff --git a/src/StackExchange.Redis/HealthCheck.PingProbe.cs b/src/StackExchange.Redis/Availability/HealthCheck.PingProbe.cs similarity index 93% rename from src/StackExchange.Redis/HealthCheck.PingProbe.cs rename to src/StackExchange.Redis/Availability/HealthCheck.PingProbe.cs index 37257e52d5..594a1bbebd 100644 --- a/src/StackExchange.Redis/HealthCheck.PingProbe.cs +++ b/src/StackExchange.Redis/Availability/HealthCheck.PingProbe.cs @@ -1,7 +1,6 @@ -using System.Net; using System.Threading.Tasks; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; public sealed partial class HealthCheck { diff --git a/src/StackExchange.Redis/HealthCheck.StringSetProbe.cs b/src/StackExchange.Redis/Availability/HealthCheck.StringSetProbe.cs similarity index 97% rename from src/StackExchange.Redis/HealthCheck.StringSetProbe.cs rename to src/StackExchange.Redis/Availability/HealthCheck.StringSetProbe.cs index fb12baf4f9..1a6bd2d2d9 100644 --- a/src/StackExchange.Redis/HealthCheck.StringSetProbe.cs +++ b/src/StackExchange.Redis/Availability/HealthCheck.StringSetProbe.cs @@ -2,7 +2,7 @@ using System.Buffers; using System.Threading.Tasks; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; public sealed partial class HealthCheck { diff --git a/src/StackExchange.Redis/HealthCheck.cs b/src/StackExchange.Redis/Availability/HealthCheck.cs similarity index 98% rename from src/StackExchange.Redis/HealthCheck.cs rename to src/StackExchange.Redis/Availability/HealthCheck.cs index 4cae83f6e6..7b13520349 100644 --- a/src/StackExchange.Redis/HealthCheck.cs +++ b/src/StackExchange.Redis/Availability/HealthCheck.cs @@ -4,7 +4,7 @@ using System.Threading; using RESPite; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; /// /// Describes a health check to perform against instances. diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Arrays.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Arrays.Async.cs similarity index 99% rename from src/StackExchange.Redis/MultiGroupDatabase.Arrays.Async.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.Arrays.Async.cs index 01951451fe..299159b430 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.Arrays.Async.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Arrays.Async.cs @@ -1,6 +1,6 @@ using System.Threading.Tasks; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Arrays.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Arrays.cs similarity index 99% rename from src/StackExchange.Redis/MultiGroupDatabase.Arrays.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.Arrays.cs index 6f5688ddc9..45405e0520 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.Arrays.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Arrays.cs @@ -1,4 +1,4 @@ -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Async.cs similarity index 99% rename from src/StackExchange.Redis/MultiGroupDatabase.Async.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.Async.cs index b1e70bf34e..1e8dbc05cb 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.Async.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Async.cs @@ -2,7 +2,7 @@ using System.Net; using System.Threading.Tasks; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Geo.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Geo.Async.cs similarity index 98% rename from src/StackExchange.Redis/MultiGroupDatabase.Geo.Async.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.Geo.Async.cs index 422ff59fc9..32965a40e6 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.Geo.Async.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Geo.Async.cs @@ -1,6 +1,6 @@ using System.Threading.Tasks; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Geo.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Geo.cs similarity index 98% rename from src/StackExchange.Redis/MultiGroupDatabase.Geo.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.Geo.cs index c74982c835..662c4de3f1 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.Geo.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Geo.cs @@ -1,4 +1,4 @@ -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Hashes.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Hashes.Async.cs similarity index 99% rename from src/StackExchange.Redis/MultiGroupDatabase.Hashes.Async.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.Hashes.Async.cs index 15e8111e83..bba401aa88 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.Hashes.Async.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Hashes.Async.cs @@ -1,7 +1,7 @@ using System; using System.Threading.Tasks; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Hashes.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Hashes.cs similarity index 99% rename from src/StackExchange.Redis/MultiGroupDatabase.Hashes.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.Hashes.cs index 312a4dcb06..3adf40c7db 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.Hashes.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Hashes.cs @@ -1,6 +1,6 @@ using System; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.HyperLogLog.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.HyperLogLog.Async.cs similarity index 96% rename from src/StackExchange.Redis/MultiGroupDatabase.HyperLogLog.Async.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.HyperLogLog.Async.cs index d57166885d..933d0131e2 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.HyperLogLog.Async.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.HyperLogLog.Async.cs @@ -1,6 +1,6 @@ using System.Threading.Tasks; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.HyperLogLog.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.HyperLogLog.cs similarity index 96% rename from src/StackExchange.Redis/MultiGroupDatabase.HyperLogLog.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.HyperLogLog.cs index 3312b4fc03..b222deea19 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.HyperLogLog.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.HyperLogLog.cs @@ -1,4 +1,4 @@ -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Keys.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Keys.Async.cs similarity index 98% rename from src/StackExchange.Redis/MultiGroupDatabase.Keys.Async.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.Keys.Async.cs index 76c7798bed..a944d57f49 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.Keys.Async.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Keys.Async.cs @@ -1,7 +1,7 @@ using System; using System.Threading.Tasks; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Keys.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Keys.cs similarity index 98% rename from src/StackExchange.Redis/MultiGroupDatabase.Keys.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.Keys.cs index 2ee6bdce05..119536084e 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.Keys.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Keys.cs @@ -1,6 +1,6 @@ using System; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Lists.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Lists.Async.cs similarity index 99% rename from src/StackExchange.Redis/MultiGroupDatabase.Lists.Async.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.Lists.Async.cs index d284dfee9b..74cf098436 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.Lists.Async.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Lists.Async.cs @@ -1,6 +1,6 @@ using System.Threading.Tasks; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Lists.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Lists.cs similarity index 99% rename from src/StackExchange.Redis/MultiGroupDatabase.Lists.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.Lists.cs index 58ffc7a1b1..fad08222da 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.Lists.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Lists.cs @@ -1,4 +1,4 @@ -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Sets.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Sets.Async.cs similarity index 98% rename from src/StackExchange.Redis/MultiGroupDatabase.Sets.Async.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.Sets.Async.cs index 7d8eddf549..90341cb369 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.Sets.Async.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Sets.Async.cs @@ -1,6 +1,6 @@ using System.Threading.Tasks; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Sets.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Sets.cs similarity index 98% rename from src/StackExchange.Redis/MultiGroupDatabase.Sets.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.Sets.cs index f04df3eebb..443039b487 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.Sets.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Sets.cs @@ -1,4 +1,4 @@ -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.SortedSets.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.SortedSets.Async.cs similarity index 99% rename from src/StackExchange.Redis/MultiGroupDatabase.SortedSets.Async.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.SortedSets.Async.cs index 9f79cd4235..e89cb3ae68 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.SortedSets.Async.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.SortedSets.Async.cs @@ -1,6 +1,6 @@ using System.Threading.Tasks; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.SortedSets.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.SortedSets.cs similarity index 99% rename from src/StackExchange.Redis/MultiGroupDatabase.SortedSets.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.SortedSets.cs index ad1a85aab5..2e237dae8b 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.SortedSets.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.SortedSets.cs @@ -1,6 +1,4 @@ -using System; - -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Streams.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Streams.Async.cs similarity index 99% rename from src/StackExchange.Redis/MultiGroupDatabase.Streams.Async.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.Streams.Async.cs index 584cabd0f9..bef1a96cdc 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.Streams.Async.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Streams.Async.cs @@ -1,7 +1,7 @@ using System; using System.Threading.Tasks; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Streams.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Streams.cs similarity index 99% rename from src/StackExchange.Redis/MultiGroupDatabase.Streams.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.Streams.cs index a9061464f9..e10c847fcb 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.Streams.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Streams.cs @@ -1,6 +1,6 @@ using System; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Strings.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Strings.Async.cs similarity index 99% rename from src/StackExchange.Redis/MultiGroupDatabase.Strings.Async.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.Strings.Async.cs index 044fffb2f7..5e4447b3bd 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.Strings.Async.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Strings.Async.cs @@ -1,7 +1,7 @@ using System; using System.Threading.Tasks; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.Strings.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Strings.cs similarity index 99% rename from src/StackExchange.Redis/MultiGroupDatabase.Strings.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.Strings.cs index 98e3d9ccca..b29d4a771a 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.Strings.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Strings.cs @@ -1,6 +1,6 @@ using System; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.VectorSets.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.VectorSets.Async.cs similarity index 98% rename from src/StackExchange.Redis/MultiGroupDatabase.VectorSets.Async.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.VectorSets.Async.cs index f4c11eb247..b72b6ced16 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.VectorSets.Async.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.VectorSets.Async.cs @@ -1,6 +1,6 @@ using System.Threading.Tasks; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.VectorSets.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.VectorSets.cs similarity index 98% rename from src/StackExchange.Redis/MultiGroupDatabase.VectorSets.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.VectorSets.cs index 64470da4ee..e7a4164db0 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.VectorSets.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.VectorSets.cs @@ -1,4 +1,4 @@ -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase { diff --git a/src/StackExchange.Redis/MultiGroupDatabase.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs similarity index 99% rename from src/StackExchange.Redis/MultiGroupDatabase.cs rename to src/StackExchange.Redis/Availability/MultiGroupDatabase.cs index 64728438f8..77e0fe4d53 100644 --- a/src/StackExchange.Redis/MultiGroupDatabase.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs @@ -1,6 +1,6 @@ using System; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase(MultiGroupMultiplexer parent, int database, object? asyncState) : IDatabase diff --git a/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs new file mode 100644 index 0000000000..b4e47fcce9 --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs @@ -0,0 +1,1230 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using RESPite; +using StackExchange.Redis.Availability; +using StackExchange.Redis.Maintenance; +using StackExchange.Redis.Profiling; + +namespace StackExchange.Redis +{ + public partial class ConnectionMultiplexer + { + /// + /// Creates a new instance that manages connections to multiple + /// redundant configurations, based on their availability and relative . + /// + /// The initial configurations to connect to. + /// Additional options for configuring this group. + /// The to log to. +#pragma warning disable RS0026 + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + public static Task ConnectGroupAsync( + ConnectionGroupMember[] members, + MultiGroupOptions? options = null, + TextWriter? log = null) +#pragma warning restore RS0026 + { + // create a defensive copy of the array; we don't want callers being able to radically swap things! + members = (ConnectionGroupMember[])members.Clone(); + options ??= MultiGroupOptions.Default; + options.Freeze(); + return MultiGroupMultiplexer.ConnectAsync(members, options, log); + } + + /// + /// Creates a new instance that manages connections to multiple + /// redundant configurations, based on their availability and relative . + /// + /// An initial configuration to connect to. + /// An additional initial configuration to connect to. + /// Additional options for configuring this group. + /// The to log to. + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +#pragma warning disable RS0026 + public static Task ConnectGroupAsync( + ConnectionGroupMember member0, + ConnectionGroupMember member1, + MultiGroupOptions? options = null, + TextWriter? log = null) +#pragma warning restore RS0026 + { + options ??= MultiGroupOptions.Default; + options.Freeze(); + return MultiGroupMultiplexer.ConnectAsync([member0, member1], options, log); + } + } + +#pragma warning disable SA1403 + namespace Availability +#pragma warning restore SA1403 + { + /// + /// A configured member of a . + /// + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +#pragma warning disable RS0016, RS0026 + public sealed partial class ConnectionGroupMember(ConfigurationOptions configuration, string name = "") +#pragma warning restore RS0016, RS0026 + { + /// + /// Create a new from a configuration string. + /// +#pragma warning disable RS0016, RS0026 + public ConnectionGroupMember(string configuration, string name = "") : this( + ConfigurationOptions.Parse(configuration)) +#pragma warning restore RS0016, RS0026 + { + } + + internal ConfigurationOptions Configuration => configuration; + + private int _activated; // each member can only be activated once + + /// + public override string ToString() => Name; + + private ConnectionMultiplexer? _muxer; + + internal ConnectionMultiplexer Multiplexer => _muxer ?? ThrowNoMuxer(); + + [DoesNotReturn] + private static ConnectionMultiplexer ThrowNoMuxer() => + throw new InvalidOperationException("Member is not connected."); + + // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract + internal void SetMultiplexer(ConnectionMultiplexer muxer) + => Interlocked.Exchange(ref _muxer, muxer ?? ThrowNoMuxer()); + + internal ConnectionMultiplexer? ClearMultiplexer() => Interlocked.Exchange(ref _muxer, null); + + internal void Init(int index) + { + // add a name if not provided + if (string.IsNullOrWhiteSpace(Name)) + { + var ep = Configuration.EndPoints.FirstOrDefault(); + if (ep is null) + { + Name = index.ToString(); + } + else + { + Name = Format.ToString(ep); + } + } + + // check not already attached + if (Interlocked.CompareExchange(ref _activated, 1, 0) != 0) + { + throw new InvalidOperationException( + $"Member '{Name}' is already associated with a group, and cannot be reused."); + } + } + + /// + /// Indicates whether this group is currently connected. + /// + public bool IsConnected { get; private set; } + + /// + /// The name of this group member. + /// + public string Name { get; private set; } = name; + + /// + /// The relative weight of this group member; higher is preferred. + /// + public double Weight + { + // avoid "tearing", since we can't rule out this being updated concurrently, and the runtime + // only guarantees atomicity for 32-bit reads/writes + get => BitConverter.Int64BitsToDouble(Interlocked.Read(ref _weight64)); + set => Interlocked.Exchange(ref _weight64, BitConverter.DoubleToInt64Bits(value)); + } + + internal bool ExplicitOverride { get; set; } + + private long _weight64 = BitConverter.DoubleToInt64Bits(1.0); + + /// + /// The measured latency to this member. + /// + public TimeSpan Latency => + _latencyTicks is uint.MaxValue ? TimeSpan.MaxValue : TimeSpan.FromTicks(_latencyTicks); + + private uint _latencyTicks = uint.MaxValue; + + internal void SetLatency(uint ticks) => _latencyTicks = ticks; + + internal static uint ToLatencyTicks(TimeSpan latency) + { + long ticks = latency.Ticks; + if (ticks <= 0) + { + return 0; + } + + return ticks > uint.MaxValue ? uint.MaxValue : (uint)ticks; + } + + internal void SetLatency(TimeSpan latency) => SetLatency(ToLatencyTicks(latency)); + + internal static ConnectionGroupMember? Select(ConnectionGroupMember? x, ConnectionGroupMember? y) + { + if (x is null) return y; + if (y is null) return x; + + // always prefer a connected endpoint + bool xc = x.IsConnected, yc = y.IsConnected; + if (xc != yc) return xc ? x : y; + + // prefer manual override if only one is overridden + xc = x.ExplicitOverride; + yc = y.ExplicitOverride; + if (xc != yc) return xc ? x : y; + + // prefer higher weight + double xw = x.Weight, yw = y.Weight; + // ReSharper disable once CompareOfFloatsByEqualityOperator + if (xw != yw) return xw > yw ? x : y; + + // then by latency + uint xl = x._latencyTicks, yl = y._latencyTicks; + return xl <= yl ? x : y; + } + + internal GroupConnectionChangedEventArgs.ChangeType UpdateState(HealthCheck.HealthCheckResult result) + { + bool isConnected; + if (_muxer is { IsConnected: true } muxer) + { + isConnected = result is not HealthCheck.HealthCheckResult.Unhealthy; + SetLatency(muxer.UpdateLatency()); + } + else + { + isConnected = false; + } + + var oldConnected = IsConnected; + IsConnected = isConnected; + + return isConnected == oldConnected ? GroupConnectionChangedEventArgs.ChangeType.Unknown + : isConnected ? GroupConnectionChangedEventArgs.ChangeType.Reconnected + : GroupConnectionChangedEventArgs.ChangeType.Disconnected; + } + + internal void UpdateLatency() + { + if (_muxer is { } muxer) SetLatency(muxer.UpdateLatency()); + } + } + + internal sealed partial class MultiGroupMultiplexer : IConnectionGroup + { + private ConnectionMultiplexer? _active; + private ConnectionGroupMember[] _members; + + public override string ToString() + { + var muxer = _active; + ConnectionGroupMember? member = null; + if (muxer is not null) + { + foreach (var m in _members) + { + if (ReferenceEquals(muxer, m.Multiplexer)) + { + member = m; + break; + } + } + } + + return member is null ? "No active connection" : $"Connected to {member.Name}"; + } + + public ReadOnlySpan GetMembers() => _members; + + internal ConnectionMultiplexer Active + { + get + { + return _active ?? Throw(); + + [DoesNotReturn] + static ConnectionMultiplexer Throw() => + throw new InvalidOperationException("All connections are unavailable."); + } + } + + // non-throwing twin of Active, for callers that have a trivial answer when the group is fully down + internal ConnectionMultiplexer? TryGetActive() => _active; + + // a completed "no endpoint" result, shared by the database/subscriber facades when the group is fully down + internal static readonly Task NoEndpoint = Task.FromResult(null); + + private ConnectionGroupMember? GetActiveMember() + { + var active = _active; + foreach (var member in _members) + { + if (ReferenceEquals(active, member.Multiplexer)) + { + return member; + } + } + + return null; + } + + ConnectionGroupMember? IConnectionGroup.ActiveMember => GetActiveMember(); + + internal ConnectionGroupMember ActiveMember + { + get + { + return GetActiveMember() ?? Throw(); + + [DoesNotReturn] + static ConnectionGroupMember Throw() => + throw new InvalidOperationException("All connections are unavailable."); + } + } + + internal static async Task ConnectAsync( + ConnectionGroupMember[] members, + MultiGroupOptions options, + TextWriter? log) + { + for (int i = 0; i < members.Length; i++) + { + members[i].Init(i); + } + + var pending = new Task[members.Length]; + for (int i = 0; i < members.Length; i++) + { + var config = members[i].Configuration; + config.AbortOnConnectFail = false; + config.HeartbeatConsistencyChecks = true; + config.CircuitBreaker ??= options.CircuitBreaker; // AA options flow into the children + pending[i] = ConnectionMultiplexer.ConnectAsync(config, log); + } + + for (int i = 0; i < pending.Length; i++) + { + var muxer = await pending[i].ConfigureAwait(false); + members[i].SetMultiplexer(muxer); + } + + // run initial healthcheck and begin + var result = new MultiGroupMultiplexer(members, options); + await TryHealthCheckAndSelectPreferredGroupAsync(result).ForAwait(); + result.StartPolling(); + return result; + } + + private readonly MultiGroupOptions _options; + + private MultiGroupMultiplexer(ConnectionGroupMember[] members, MultiGroupOptions options) + { + _options = options; + _members = members; + _active = null; + + _connectionFailedWithCircuitBreaker = OnMemberConnectionFailed; + // multiplexers should already be attached (ConnectAsync sets them before constructing us) + foreach (var member in members) + { + member.Multiplexer?.ConnectionFailed += _connectionFailedWithCircuitBreaker; + } + } + + internal static async Task TryHealthCheckAndSelectPreferredGroupAsync(object? target) + { + if (target is MultiGroupMultiplexer typed) + { + if (typed.IsDisposed) return false; + + // serialize health-check + select: the poll loop and the circuit-breaker fast-path + // can both land here, and we must not run two overlapping check/select passes (they + // would race _active and emit duplicate change events). If a pass is already running, + // skip - it will select on our behalf, and we converge on the next tick regardless. + if (Interlocked.CompareExchange(ref typed._healthCheckGate, 1, 0) is not 0) + { + return true; // still a live target; keep polling + } + + try + { + await typed.RunHealthCheckAsync().ForAwait(); + typed.SelectPreferredGroup(); + } + catch (Exception ex) + { + typed.OnInternalError(ex, origin: "update group"); + } + finally + { + Volatile.Write(ref typed._healthCheckGate, 0); + } + + return true; // even if we fault: try again + } + + return false; + } + + private void StartPolling() + { + // use a weak-ref to avoid the loop keeping the object alive; capturing the token (rather than + // the muxer) lets us break out of the delay promptly on dispose without resurrecting the target + var cancellationToken = _pollCancellation.Token; + _ = Task.Run(() => PollAsync(new(this), cancellationToken)); + + static async Task PollAsync(WeakReference weakRef, CancellationToken cancellationToken) + { + while (TryGetHealthCheck(weakRef.Target, out var interval)) + { + try + { + await Task.Delay(interval, cancellationToken).ForAwait(); + } + catch (OperationCanceledException) + { + break; // disposed; stop polling without waiting out the interval + } + + if (!await TryHealthCheckAndSelectPreferredGroupAsync(weakRef.Target).ForAwait()) break; + } + } + + static bool TryGetHealthCheck(object? target, out TimeSpan interval) + { + if (target is MultiGroupMultiplexer typed + && typed._options.HealthCheck is { } healthCheck) + { + interval = healthCheck.Interval; + return interval > TimeSpan.Zero & interval != TimeSpan.MaxValue; + } + + interval = TimeSpan.Zero; + return false; + } + } + + internal bool IsDisposed => _disposed; + + private Task[]? _reusableHealthCheckBuffer; + private int _healthCheckGate; // 0 = idle, 1 = a check/select pass is in flight (see TryHealthCheckAndSelectPreferredGroupAsync) + + internal async Task RunHealthCheckAsync() + { + if (_disposed) return; + var healthCheck = _options.HealthCheck; + var members = _members; + var pending = HealthCheck.GetReusablePending(ref _reusableHealthCheckBuffer, members.Length); + for (int i = 0; i < members.Length; i++) + { + pending[i] = healthCheck.CheckHealthAsync(members[i].Multiplexer); + } + + await Task.WhenAll(pending).TimeoutAfter(healthCheck.TotalTimeoutMillis()).ForAwait(); + for (int i = 0; i < pending.Length; i++) + { + HealthCheck.HealthCheckResult result; + if (pending[i].IsCompletedSuccessfully) + { + result = await pending[i].ForAwait(); + } + else + { + _ = pending[i].ObserveErrors(); + result = HealthCheck.HealthCheckResult.Unhealthy; + } + + var delta = members[i].UpdateState(result); + if (delta != GroupConnectionChangedEventArgs.ChangeType.Unknown) + { + OnConnectionChanged(delta, members[i]); + } + } + + HealthCheck.PutReusablePending(ref _reusableHealthCheckBuffer, ref pending); + } + + internal void SelectPreferredGroup() + { + if (_disposed) return; + var previousMuxer = _active; + ConnectionGroupMember? preferredMember = null, previousMember = null; + var members = _members; + foreach (var member in members) + { + if (previousMember is null && ReferenceEquals(member.Multiplexer, previousMuxer)) + { + previousMember = member; + } + + if (member.IsConnected) + { + member.UpdateLatency(); // this can change passively + preferredMember = ConnectionGroupMember.Select(preferredMember, member); + } + } + + _active = preferredMember?.Multiplexer; + + if (preferredMember is not null && !ReferenceEquals(preferredMember, previousMember)) + { + OnConnectionChanged( + GroupConnectionChangedEventArgs.ChangeType.ActiveChanged, + preferredMember, + previousMember); + } + } + + private readonly CancellationTokenSource _pollCancellation = new(); + + private List DropAll() + { + _pollCancellation.Cancel(); // stop the polling loop promptly (idempotent) + _active = null; + var members = Interlocked.Exchange(ref _members, []); + if (members.Length is 0) return []; + var muxers = new List(members.Length); + foreach (var member in members) + { + var muxer = member.ClearMultiplexer(); + if (muxer is not null) muxers.Add(muxer); + } + + return muxers; + } + + private bool _disposed; + + public void Dispose() + { + _disposed = true; + foreach (var muxer in DropAll()) + { + muxer.Dispose(); + } + } + + public async ValueTask DisposeAsync() + { + _disposed = true; + foreach (var muxer in DropAll()) + { + await muxer.DisposeAsync(); + } + } + + public string ClientName => Active.ClientName; + public string Configuration => Active.Configuration; + public int TimeoutMilliseconds => Active.TimeoutMilliseconds; + + public long OperationCount + { + get + { + long count = 0; + foreach (var member in _members) + { + count += member.Multiplexer.OperationCount; + } + + return count; + } + } + + [Obsolete] + public bool PreserveAsyncOrder + { + get => Active.PreserveAsyncOrder; + set => Active.PreserveAsyncOrder = value; + } + + // Unlike most members, these intentionally do *not* go via Active (which throws when no member is + // available); callers routinely use IsConnected/IsConnecting as a pre-flight check and expect a + // 'false' result - not an exception - when the entire group is down. + public bool IsConnected => _active?.IsConnected ?? false; + public bool IsConnecting => _active?.IsConnecting ?? false; + + [Obsolete] + public bool IncludeDetailInExceptions + { + get => Active.IncludeDetailInExceptions; + set => Active.IncludeDetailInExceptions = value; + } + + public int StormLogThreshold + { + get => Active.StormLogThreshold; + set => Active.StormLogThreshold = value; + } + + private Func? _profilingSessionProvider; + + public void RegisterProfiler(Func profilingSessionProvider) + { + _profilingSessionProvider = profilingSessionProvider; + foreach (var member in _members) + { + member.Multiplexer.RegisterProfiler(profilingSessionProvider); + } + } + + public ServerCounters GetCounters() => Active.GetCounters(); + + private EventHandler? _errorMessage; + + public event EventHandler? ErrorMessage + { + add + { + if (AddHandler(ref _errorMessage, value)) + { + foreach (var member in _members) + { + member.Multiplexer.ErrorMessage += value; + } + } + } + remove + { + if (RemoveHandler(ref _errorMessage, value)) + { + foreach (var member in _members) + { + member.Multiplexer.ErrorMessage -= value; + } + } + } + } + + /// + /// Add a handler, and return true if this is the *first* handler, which means we should subscribe the dependents. + /// + private static bool AddHandler(ref T? field, T? value) where T : Delegate + { + if (value is null) return false; + while (true) // loop until we win (competition) + { + var oldValue = field; + var newValue = oldValue is null ? value : (T)Delegate.Combine(oldValue, value); + + if (ReferenceEquals(Interlocked.CompareExchange(ref field, newValue, oldValue), oldValue)) + { + return oldValue is null; + } + } + } + + /// + /// Remove a handler, and return true if this is the *last* handler, which means we should unsubscribe the dependents. + /// + private static bool RemoveHandler(ref T? field, T? value) where T : Delegate + { + if (value is null) return false; + while (true) // loop until we win (competition) + { + var oldValue = field; + var newValue = oldValue is null ? null : (T?)Delegate.Remove(oldValue, value); + + if (ReferenceEquals(Interlocked.CompareExchange(ref field, newValue, oldValue), oldValue)) + { + return newValue is null; + } + } + } + + private int _circuitBreakerDebounce = 0; + private void OnMemberConnectionFailed(object? sender, ConnectionFailedEventArgs e) + { + // deliberately scoped to this one failure type; re-probe and re-select promptly rather + // than waiting for the next poll tick, so traffic routes away from the dropped member + if (e.FailureType is ConnectionFailureType.CircuitBreaker + && Interlocked.CompareExchange(ref _circuitBreakerDebounce, 1, 0) is 0) + { + _ = Task.Run(async () => + { + try + { + await TryHealthCheckAndSelectPreferredGroupAsync(this); + } + catch (Exception ex) + { + Debug.WriteLine(ex.Message); + } + finally + { + Volatile.Write(ref _circuitBreakerDebounce, 0); + } + }); + } + + // invoke any custom logic from consumers subscribing to the public events + _connectionFailedExternal?.Invoke(sender, e); + } + + /// + /// Subscribe a child multiplexer to all local event handlers that have subscribers. + /// + private void AddEventHandlers(ConnectionMultiplexer muxer) + { + muxer.ErrorMessage += _errorMessage; + muxer.ConnectionFailed += _connectionFailedWithCircuitBreaker; // always non-null + muxer.InternalError += _internalError; + muxer.ConnectionRestored += _connectionRestored; + muxer.ConfigurationChanged += _configurationChanged; + muxer.ConfigurationChangedBroadcast += _configurationChangedBroadcast; + muxer.ServerMaintenanceEvent += _serverMaintenanceEvent; + muxer.HashSlotMoved += _hashSlotMoved; + } + + /// + /// Unsubscribe a child multiplexer from all local event handlers. + /// + private void RemoveEventHandlers(ConnectionMultiplexer? muxer) + { + if (muxer is null) return; + muxer.ErrorMessage -= _errorMessage; + muxer.ConnectionFailed -= _connectionFailedWithCircuitBreaker; // always non-null + muxer.InternalError -= _internalError; + muxer.ConnectionRestored -= _connectionRestored; + muxer.ConfigurationChanged -= _configurationChanged; + muxer.ConfigurationChangedBroadcast -= _configurationChangedBroadcast; + muxer.ServerMaintenanceEvent -= _serverMaintenanceEvent; + muxer.HashSlotMoved -= _hashSlotMoved; + } + + private readonly EventHandler _connectionFailedWithCircuitBreaker; + + private EventHandler? _connectionFailedExternal; // from public callers + + public event EventHandler? ConnectionFailed + { + // note we *do not* need to hook/unhook the inner connection - we always subscribe to that + add => AddHandler(ref _connectionFailedExternal, value); + remove => RemoveHandler(ref _connectionFailedExternal, value); + } + + private EventHandler? _internalError; + + public event EventHandler? InternalError + { + add + { + if (AddHandler(ref _internalError, value)) + { + foreach (var member in _members) + { + member.Multiplexer.InternalError += value; + } + } + } + remove + { + if (RemoveHandler(ref _internalError, value)) + { + foreach (var member in _members) + { + member.Multiplexer.InternalError -= value; + } + } + } + } + + private EventHandler? _connectionRestored; + + public event EventHandler? ConnectionRestored + { + add + { + if (AddHandler(ref _connectionRestored, value)) + { + foreach (var member in _members) + { + member.Multiplexer.ConnectionRestored += value; + } + } + } + remove + { + if (RemoveHandler(ref _connectionRestored, value)) + { + foreach (var member in _members) + { + member.Multiplexer.ConnectionRestored -= value; + } + } + } + } + + private EventHandler? _configurationChanged; + + public event EventHandler? ConfigurationChanged + { + add + { + if (AddHandler(ref _configurationChanged, value)) + { + foreach (var member in _members) + { + member.Multiplexer.ConfigurationChanged += value; + } + } + } + remove + { + if (RemoveHandler(ref _configurationChanged, value)) + { + foreach (var member in _members) + { + member.Multiplexer.ConfigurationChanged -= value; + } + } + } + } + + private EventHandler? _configurationChangedBroadcast; + + public event EventHandler? ConfigurationChangedBroadcast + { + add + { + if (AddHandler(ref _configurationChangedBroadcast, value)) + { + foreach (var member in _members) + { + member.Multiplexer.ConfigurationChangedBroadcast += value; + } + } + } + remove + { + if (RemoveHandler(ref _configurationChangedBroadcast, value)) + { + foreach (var member in _members) + { + member.Multiplexer.ConfigurationChangedBroadcast -= value; + } + } + } + } + + private EventHandler? _serverMaintenanceEvent; + + public event EventHandler? ServerMaintenanceEvent + { + add + { + if (AddHandler(ref _serverMaintenanceEvent, value)) + { + foreach (var member in _members) + { + member.Multiplexer.ServerMaintenanceEvent += value; + } + } + } + remove + { + if (RemoveHandler(ref _serverMaintenanceEvent, value)) + { + foreach (var member in _members) + { + member.Multiplexer.ServerMaintenanceEvent -= value; + } + } + } + } + + public EndPoint[] GetEndPoints(bool configuredOnly = false) => Active.GetEndPoints(configuredOnly); + + public void Wait(Task task) => Active.Wait(task); + + public T Wait(Task task) => Active.Wait(task); + + public void WaitAll(params Task[] tasks) => Active.WaitAll(tasks); + + private EventHandler? _hashSlotMoved; + + public event EventHandler? HashSlotMoved + { + add + { + if (AddHandler(ref _hashSlotMoved, value)) + { + foreach (var member in _members) + { + member.Multiplexer.HashSlotMoved += value; + } + } + } + remove + { + if (RemoveHandler(ref _hashSlotMoved, value)) + { + foreach (var member in _members) + { + member.Multiplexer.HashSlotMoved -= value; + } + } + } + } + + public int HashSlot(RedisKey key) => Active.HashSlot(key); + + private ISubscriber? _defaultSubscriber; + + public ISubscriber GetSubscriber(object? asyncState = null) + { + if (asyncState is null) return _defaultSubscriber ??= new MultiGroupSubscriber(this, null); + return new MultiGroupSubscriber(this, asyncState); + } + + public IDatabase GetDatabase(int db = -1, object? asyncState = null) + { + if (asyncState is null & db >= -1 & db <= ConnectionMultiplexer.MaxCachedDatabaseInstance) + { + return _databases[db + 1] ??= new MultiGroupDatabase(this, db, null); + } + + return new MultiGroupDatabase(this, db, asyncState); + } + + private readonly IDatabase?[] _databases = + new IDatabase?[ConnectionMultiplexer.MaxCachedDatabaseInstance + 2]; + + public IServer GetServer(string host, int port, object? asyncState = null) + { + Exception ex; + try + { + // try "active" first, and preserve the exception + return Active.GetServer(host, port, asyncState); + } + catch (Exception e) + { + ex = e; + } + + foreach (var member in _members) + { + try + { + return member.Multiplexer.GetServer(host, port, asyncState); + } + catch (Exception e) { Debug.WriteLine(e.Message); } + } + + throw ex; + } + + public IServer GetServer(string hostAndPort, object? asyncState = null) + { + Exception ex; + try + { + // try "active" first, and preserve the exception + return Active.GetServer(hostAndPort, asyncState); + } + catch (Exception e) + { + ex = e; + } + + foreach (var member in _members) + { + try + { + return member.Multiplexer.GetServer(hostAndPort, asyncState); + } + catch (Exception e) { Debug.WriteLine(e.Message); } + } + + throw ex; + } + + public IServer GetServer(IPAddress host, int port) + { + Exception ex; + try + { + // try "active" first, and preserve the exception + return Active.GetServer(host, port); + } + catch (Exception e) + { + ex = e; + } + + foreach (var member in _members) + { + try + { + return member.Multiplexer.GetServer(host, port); + } + catch (Exception e) { Debug.WriteLine(e.Message); } + } + + throw ex; + } + + public IServer GetServer(EndPoint endpoint, object? asyncState = null) + { + Exception ex; + try + { + // try "active" first, and preserve the exception + return Active.GetServer(endpoint, asyncState); + } + catch (Exception e) + { + ex = e; + } + + foreach (var member in _members) + { + try + { + return member.Multiplexer.GetServer(endpoint, asyncState); + } + catch (Exception e) { Debug.WriteLine(e.Message); } + } + + throw ex; + } + + public IServer GetServer(RedisKey key, object? asyncState = null, CommandFlags flags = CommandFlags.None) => + Active.GetServer(key, asyncState, flags); + + public IServer[] GetServers() => Active.GetServers(); + + public Task ConfigureAsync(TextWriter? log = null) => Active.ConfigureAsync(log); + + public bool Configure(TextWriter? log = null) => Active.Configure(log); + + public string GetStatus() => Active.GetStatus(); + + public void GetStatus(TextWriter log) => Active.GetStatus(log); + + public void Close(bool allowCommandsToComplete = true) + { + _disposed = true; + foreach (var member in DropAll()) + { + member.Close(allowCommandsToComplete); + } + } + + public async Task CloseAsync(bool allowCommandsToComplete = true) + { + _disposed = true; + foreach (var member in DropAll()) + { + await member.CloseAsync(allowCommandsToComplete); + } + } + + public string? GetStormLog() => Active.GetStormLog(); + + public void ResetStormLog() => Active.ResetStormLog(); + + public long PublishReconfigure(CommandFlags flags = CommandFlags.None) => Active.PublishReconfigure(flags); + + public Task PublishReconfigureAsync(CommandFlags flags = CommandFlags.None) => + Active.PublishReconfigureAsync(flags); + + public int GetHashSlot(RedisKey key) => Active.GetHashSlot(key); + + public void ExportConfiguration(Stream destination, ExportOptions options = ExportOptions.All) => + Active.ExportConfiguration(destination, options); + + private readonly HashSet _suffixes = new(); // in case we need to add to a new muxer + + public void AddLibraryNameSuffix(string suffix) + { + if (string.IsNullOrWhiteSpace(suffix)) return; // trivial + bool isNew; + lock (_suffixes) + { + isNew = _suffixes.Add(suffix); + } + + if (isNew) + { + foreach (var member in _members) + { + member.Multiplexer.AddLibraryNameSuffix(suffix); + } + } + } + + public event EventHandler? ConnectionChanged; + + private void OnConnectionChanged( + GroupConnectionChangedEventArgs.ChangeType changeType, + ConnectionGroupMember member, + ConnectionGroupMember? previousMember = null) + { + var handler = ConnectionChanged; + if (handler is not null) + { + new GroupConnectionChangedEventArgs(changeType, member, previousMember) + .CompleteAsWorker(handler, this); + } + } + + public async Task AddAsync(ConnectionGroupMember member, TextWriter? log = null) + { + // connect + member.Init(_members.Length); + member.Configuration.HeartbeatConsistencyChecks = true; + member.Configuration.CircuitBreaker ??= _options.CircuitBreaker; // AA options flow into the children + var muxer = await ConnectionMultiplexer.ConnectAsync(member.Configuration, log).ConfigureAwait(false); + member.SetMultiplexer(muxer); + var health = await _options.HealthCheck.CheckHealthAsync(muxer).ConfigureAwait(false); + member.UpdateState(health); + + // apply any shared hooks + AddEventHandlers(muxer); // includes circuit-breaker + if (_profilingSessionProvider is not null) muxer.RegisterProfiler(_profilingSessionProvider); + lock (_suffixes) + { + foreach (var suffix in _suffixes) + { + muxer.AddLibraryNameSuffix(suffix); + } + } + + // update the members array + while (true) + { + var arr = _members; + var newArr = new ConnectionGroupMember[arr.Length + 1]; + Array.Copy(arr, 0, newArr, 0, arr.Length); + newArr[arr.Length] = member; + if (Interlocked.CompareExchange(ref _members, newArr, arr) == arr) break; + } + + OnConnectionChanged(GroupConnectionChangedEventArgs.ChangeType.Added, member); + SelectPreferredGroup(); + + // pub/sub + await AddPubSubHandlersAsync(member).ConfigureAwait(false); + } + + public bool TryFailoverTo(ConnectionGroupMember? member) + { + if (member is null) + { + // remove any explicit overrides, returning whether that was an actual change + bool result = false; + foreach (var m in _members) + { + if (m.ExplicitOverride) + { + result = true; // someone was explicitly enabled + m.ExplicitOverride = false; + } + } + + SelectPreferredGroup(); + return result; + } + + var members = _members; + if (!members.Contains(member)) + { + // not one of ours? + return false; + } + + if (!member.IsConnected) + { + // not allowed + return false; + } + + if (member.ExplicitOverride) + { + // already preferred; no change, but report as success + return true; + } + + // otherwise, deselect everyone else, and select this one + foreach (var m in members) + { + m.ExplicitOverride = ReferenceEquals(m, member); + } + + SelectPreferredGroup(); + return true; + } + + public bool Remove(ConnectionGroupMember member) + { + while (true) + { + var arr = _members; + int index = -1; + for (int i = 0; i < arr.Length; i++) + { + if (ReferenceEquals(arr[i], member)) + { + index = i; + break; + } + } + + if (index == -1) return false; + var newArr = new ConnectionGroupMember[arr.Length - 1]; + if (index > 0) Array.Copy(arr, 0, newArr, 0, index); + if (index < newArr.Length) Array.Copy(arr, index + 1, newArr, index, newArr.Length - index); + if (Interlocked.CompareExchange(ref _members, newArr, arr) == arr) break; + } + + var muxer = member.ClearMultiplexer(); + RemoveEventHandlers(muxer); // includes circuit-breaker + OnConnectionChanged(GroupConnectionChangedEventArgs.ChangeType.Removed, member); + SelectPreferredGroup(); + muxer?.Dispose(); + return true; + } + + internal void OnHeartbeat() // for testing, to update latency etc + { + foreach (var member in _members) + { + member.Multiplexer.OnHeartbeat(); + } + } + + internal void OnInternalError( + Exception exception, + EndPoint? endpoint = null, + ConnectionType connectionType = ConnectionType.None, + string? origin = null) + { + var handler = _internalError; + if (handler is not null) + { + InternalErrorEventArgs args = new(handler, this, endpoint, connectionType, exception, origin); + ConnectionMultiplexer.CompleteAsWorker(args); + } + } + } + } +} diff --git a/src/StackExchange.Redis/MultiGroupOptions.cs b/src/StackExchange.Redis/Availability/MultiGroupOptions.cs similarity index 81% rename from src/StackExchange.Redis/MultiGroupOptions.cs rename to src/StackExchange.Redis/Availability/MultiGroupOptions.cs index 8f783a7376..3799836e69 100644 --- a/src/StackExchange.Redis/MultiGroupOptions.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupOptions.cs @@ -4,7 +4,7 @@ using System.Threading; using RESPite; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; /// /// Configuration options for controlling connections to multiple groups. @@ -36,6 +36,15 @@ public HealthCheck HealthCheck set => SetField(ref field, value); } + /// + /// The circuit-breaker to use for members of the group when no per-member circuit-breaker is specified. + /// + public CircuitBreaker CircuitBreaker + { + get => field ?? CircuitBreaker.Default; + set => SetField(ref field, value); + } + // ReSharper disable once RedundantAssignment private void SetField(ref T field, T value, [CallerMemberName] string caller = "") { diff --git a/src/StackExchange.Redis/MultiGroupSubscriber.Tracking.cs b/src/StackExchange.Redis/Availability/MultiGroupSubscriber.Tracking.cs similarity index 99% rename from src/StackExchange.Redis/MultiGroupSubscriber.Tracking.cs rename to src/StackExchange.Redis/Availability/MultiGroupSubscriber.Tracking.cs index 98fcf7ddf4..eb560004de 100644 --- a/src/StackExchange.Redis/MultiGroupSubscriber.Tracking.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupSubscriber.Tracking.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Threading.Tasks; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupSubscriber { diff --git a/src/StackExchange.Redis/MultiGroupSubscriber.cs b/src/StackExchange.Redis/Availability/MultiGroupSubscriber.cs similarity index 98% rename from src/StackExchange.Redis/MultiGroupSubscriber.cs rename to src/StackExchange.Redis/Availability/MultiGroupSubscriber.cs index 3ce12764de..4dbb8e9e3c 100644 --- a/src/StackExchange.Redis/MultiGroupSubscriber.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupSubscriber.cs @@ -2,7 +2,7 @@ using System.Net; using System.Threading.Tasks; -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupSubscriber(MultiGroupMultiplexer parent, object? asyncState) : ISubscriber { diff --git a/src/StackExchange.Redis/ConfigurationOptions.cs b/src/StackExchange.Redis/ConfigurationOptions.cs index 87ef467405..5a497e8252 100644 --- a/src/StackExchange.Redis/ConfigurationOptions.cs +++ b/src/StackExchange.Redis/ConfigurationOptions.cs @@ -16,6 +16,7 @@ using Microsoft.Extensions.Logging; using RESPite; using RESPite.Buffers; +using StackExchange.Redis.Availability; using StackExchange.Redis.Configuration; namespace StackExchange.Redis @@ -978,6 +979,7 @@ public static ConfigurationOptions Parse(string configuration, bool ignoreUnknow _protocol = _protocol, heartbeatInterval = heartbeatInterval, WriteMode = WriteMode, + CircuitBreaker = CircuitBreaker, #if DEBUG OutputLog = OutputLog, #endif @@ -1181,6 +1183,7 @@ private void Clear() Tunnel = null; _protocol = default; WriteMode = default; + CircuitBreaker = null; #if DEBUG OutputLog = null; #endif @@ -1379,6 +1382,11 @@ public RedisProtocol? Protocol } internal BufferedStreamWriter.WriteMode WriteMode { get; set; } + + // circuit-breaker to apply to physical connections; when null, no breaker is used. Not surfaced as + // public API for now (minimizing drift); flows in from MultiGroupOptions when connecting a group. + internal CircuitBreaker? CircuitBreaker { get; set; } + internal bool AllowSimulateConnectionFailure { get => IsSet(OptionFlags.AllowSimulateConnectionFailure); diff --git a/src/StackExchange.Redis/Enums/ConnectionFailureType.cs b/src/StackExchange.Redis/Enums/ConnectionFailureType.cs index 55eeacef6f..d771df9db7 100644 --- a/src/StackExchange.Redis/Enums/ConnectionFailureType.cs +++ b/src/StackExchange.Redis/Enums/ConnectionFailureType.cs @@ -1,4 +1,7 @@ -namespace StackExchange.Redis +using System.Diagnostics.CodeAnalysis; +using RESPite; + +namespace StackExchange.Redis { /// /// The known types of connection failure. @@ -59,5 +62,11 @@ public enum ConnectionFailureType /// High-integrity mode was enabled, and a failure was detected. /// ResponseIntegrityFailure, + + /// + /// The associated with this connection detected instability. + /// + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + CircuitBreaker, } } diff --git a/src/StackExchange.Redis/Interfaces/IConnectionGroup.cs b/src/StackExchange.Redis/Interfaces/IConnectionGroup.cs index 1afd732908..7f78328e31 100644 --- a/src/StackExchange.Redis/Interfaces/IConnectionGroup.cs +++ b/src/StackExchange.Redis/Interfaces/IConnectionGroup.cs @@ -4,9 +4,10 @@ using System.Text; using System.Threading.Tasks; using RESPite; +using StackExchange.Redis.Availability; // ReSharper disable once CheckNamespace -namespace StackExchange.Redis; +namespace StackExchange.Redis.Availability; /// /// A group of connections to redis servers, that manages connections to multiple diff --git a/src/StackExchange.Redis/Message.cs b/src/StackExchange.Redis/Message.cs index 17ded6c20c..b37995b78e 100644 --- a/src/StackExchange.Redis/Message.cs +++ b/src/StackExchange.Redis/Message.cs @@ -483,18 +483,21 @@ public string ToStringCommandOnly() => bool ICompletable.TryComplete(bool isAsync) { - Complete(); + Complete(null); return true; } - public void Complete() + public void Complete(PhysicalConnection? connection) { // Ensure we can never call Complete on the same resultBox from two threads by grabbing it now var currBox = Interlocked.Exchange(ref resultBox, null); // set the completion/performance data performance?.SetCompleted(); - + if (currBox is not null) + { + connection?.ObserveMessageResult(currBox.Fault); + } currBox?.ActivateContinuations(); } @@ -693,7 +696,7 @@ internal bool ComputeResult(PhysicalConnection connection, ref RespReader reader { connection.OnDetailLog($"{ex.GetType().Name}: {ex.Message}"); ex.Data.Add("got", prefix.ToString()); - connection?.BridgeCouldBeNull?.Multiplexer?.OnMessageFaulted(this, ex); + connection.BridgeCouldBeNull?.Multiplexer?.OnMessageFaulted(this, ex); box?.SetException(ex); return box != null; // we still want to pulse/complete } @@ -706,10 +709,10 @@ internal void Fail(ConnectionFailureType failure, Exception? innerException, str resultProcessor?.ConnectionFail(this, failure, innerException, annotation, muxer); } - internal virtual void SetExceptionAndComplete(Exception exception, PhysicalBridge? bridge) + internal virtual void SetExceptionAndComplete(Exception exception, PhysicalConnection? connection) { resultBox?.SetException(exception); - Complete(); + Complete(connection); } internal bool TrySetResult(T value) diff --git a/src/StackExchange.Redis/MultiGroupMultiplexer.cs b/src/StackExchange.Redis/MultiGroupMultiplexer.cs deleted file mode 100644 index 9c2e1d45bc..0000000000 --- a/src/StackExchange.Redis/MultiGroupMultiplexer.cs +++ /dev/null @@ -1,1163 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Linq; -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using RESPite; -using StackExchange.Redis.Maintenance; -using StackExchange.Redis.Profiling; - -namespace StackExchange.Redis; - -public partial class ConnectionMultiplexer -{ - /// - /// Creates a new instance that manages connections to multiple - /// redundant configurations, based on their availability and relative . - /// - /// The initial configurations to connect to. - /// Additional options for configuring this group. - /// The to log to. -#pragma warning disable RS0026 - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] - public static Task ConnectGroupAsync(ConnectionGroupMember[] members, MultiGroupOptions? options = null, TextWriter? log = null) -#pragma warning restore RS0026 - { - // create a defensive copy of the array; we don't want callers being able to radically swap things! - members = (ConnectionGroupMember[])members.Clone(); - options ??= MultiGroupOptions.Default; - options.Freeze(); - return MultiGroupMultiplexer.ConnectAsync(members, options, log); - } - - /// - /// Creates a new instance that manages connections to multiple - /// redundant configurations, based on their availability and relative . - /// - /// An initial configuration to connect to. - /// An additional initial configuration to connect to. - /// Additional options for configuring this group. - /// The to log to. - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] -#pragma warning disable RS0026 - public static Task ConnectGroupAsync( - ConnectionGroupMember member0, - ConnectionGroupMember member1, - MultiGroupOptions? options = null, - TextWriter? log = null) -#pragma warning restore RS0026 - { - options ??= MultiGroupOptions.Default; - options.Freeze(); - return MultiGroupMultiplexer.ConnectAsync([member0, member1], options, log); - } -} - -/// -/// A configured member of a . -/// -[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] -#pragma warning disable RS0016, RS0026 -public sealed partial class ConnectionGroupMember(ConfigurationOptions configuration, string name = "") -#pragma warning restore RS0016, RS0026 -{ - /// - /// Create a new from a configuration string. - /// -#pragma warning disable RS0016, RS0026 - public ConnectionGroupMember(string configuration, string name = "") : this( - ConfigurationOptions.Parse(configuration)) -#pragma warning restore RS0016, RS0026 - { - } - - internal ConfigurationOptions Configuration => configuration; - - private int _activated; // each member can only be activated once - - /// - public override string ToString() => Name; - - private ConnectionMultiplexer? _muxer; - - internal ConnectionMultiplexer Multiplexer => _muxer ?? ThrowNoMuxer(); - - [DoesNotReturn] - private static ConnectionMultiplexer ThrowNoMuxer() => - throw new InvalidOperationException("Member is not connected."); - - // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract - internal void SetMultiplexer(ConnectionMultiplexer muxer) - => Interlocked.Exchange(ref _muxer, muxer ?? ThrowNoMuxer()); - - internal ConnectionMultiplexer? ClearMultiplexer() => Interlocked.Exchange(ref _muxer, null); - - internal void Init(int index) - { - // add a name if not provided - if (string.IsNullOrWhiteSpace(Name)) - { - var ep = Configuration.EndPoints.FirstOrDefault(); - if (ep is null) - { - Name = index.ToString(); - } - else - { - Name = Format.ToString(ep); - } - } - - // check not already attached - if (Interlocked.CompareExchange(ref _activated, 1, 0) != 0) - { - throw new InvalidOperationException( - $"Member '{Name}' is already associated with a group, and cannot be reused."); - } - } - - /// - /// Indicates whether this group is currently connected. - /// - public bool IsConnected { get; private set; } - - /// - /// The name of this group member. - /// - public string Name { get; private set; } = name; - - /// - /// The relative weight of this group member; higher is preferred. - /// - public double Weight - { - // avoid "tearing", since we can't rule out this being updated concurrently, and the runtime - // only guarantees atomicity for 32-bit reads/writes - get => BitConverter.Int64BitsToDouble(Interlocked.Read(ref _weight64)); - set => Interlocked.Exchange(ref _weight64, BitConverter.DoubleToInt64Bits(value)); - } - - internal bool ExplicitOverride { get; set; } - - private long _weight64 = BitConverter.DoubleToInt64Bits(1.0); - - /// - /// The measured latency to this member. - /// - public TimeSpan Latency => _latencyTicks is uint.MaxValue ? TimeSpan.MaxValue : TimeSpan.FromTicks(_latencyTicks); - - private uint _latencyTicks = uint.MaxValue; - - internal void SetLatency(uint ticks) => _latencyTicks = ticks; - - internal static uint ToLatencyTicks(TimeSpan latency) - { - long ticks = latency.Ticks; - if (ticks <= 0) - { - return 0; - } - return ticks > uint.MaxValue ? uint.MaxValue : (uint)ticks; - } - - internal void SetLatency(TimeSpan latency) => SetLatency(ToLatencyTicks(latency)); - - internal static ConnectionGroupMember? Select(ConnectionGroupMember? x, ConnectionGroupMember? y) - { - if (x is null) return y; - if (y is null) return x; - - // always prefer a connected endpoint - bool xc = x.IsConnected, yc = y.IsConnected; - if (xc != yc) return xc ? x : y; - - // prefer manual override if only one is overridden - xc = x.ExplicitOverride; - yc = y.ExplicitOverride; - if (xc != yc) return xc ? x : y; - - // prefer higher weight - double xw = x.Weight, yw = y.Weight; - // ReSharper disable once CompareOfFloatsByEqualityOperator - if (xw != yw) return xw > yw ? x : y; - - // then by latency - uint xl = x._latencyTicks, yl = y._latencyTicks; - return xl <= yl ? x : y; - } - - internal GroupConnectionChangedEventArgs.ChangeType UpdateState(HealthCheck.HealthCheckResult result) - { - bool isConnected; - if (_muxer is { IsConnected: true } muxer) - { - isConnected = result is not HealthCheck.HealthCheckResult.Unhealthy; - SetLatency(muxer.UpdateLatency()); - } - else - { - isConnected = false; - } - - var oldConnected = IsConnected; - IsConnected = isConnected; - - return isConnected == oldConnected ? GroupConnectionChangedEventArgs.ChangeType.Unknown - : isConnected ? GroupConnectionChangedEventArgs.ChangeType.Reconnected - : GroupConnectionChangedEventArgs.ChangeType.Disconnected; - } - - internal void UpdateLatency() - { - if (_muxer is { } muxer) SetLatency(muxer.UpdateLatency()); - } -} - -internal sealed partial class MultiGroupMultiplexer : IConnectionGroup -{ - private ConnectionMultiplexer? _active; - private ConnectionGroupMember[] _members; - - public override string ToString() - { - var muxer = _active; - ConnectionGroupMember? member = null; - if (muxer is not null) - { - foreach (var m in _members) - { - if (ReferenceEquals(muxer, m.Multiplexer)) - { - member = m; - break; - } - } - } - - return member is null ? "No active connection" : $"Connected to {member.Name}"; - } - - public ReadOnlySpan GetMembers() => _members; - - internal ConnectionMultiplexer Active - { - get - { - return _active ?? Throw(); - - [DoesNotReturn] - static ConnectionMultiplexer Throw() => - throw new InvalidOperationException("All connections are unavailable."); - } - } - - // non-throwing twin of Active, for callers that have a trivial answer when the group is fully down - internal ConnectionMultiplexer? TryGetActive() => _active; - - // a completed "no endpoint" result, shared by the database/subscriber facades when the group is fully down - internal static readonly Task NoEndpoint = Task.FromResult(null); - - private ConnectionGroupMember? GetActiveMember() - { - var active = _active; - foreach (var member in _members) - { - if (ReferenceEquals(active, member.Multiplexer)) - { - return member; - } - } - - return null; - } - ConnectionGroupMember? IConnectionGroup.ActiveMember => GetActiveMember(); - - internal ConnectionGroupMember ActiveMember - { - get - { - return GetActiveMember() ?? Throw(); - - [DoesNotReturn] - static ConnectionGroupMember Throw() => - throw new InvalidOperationException("All connections are unavailable."); - } - } - - internal static async Task ConnectAsync(ConnectionGroupMember[] members, MultiGroupOptions options, TextWriter? log) - { - for (int i = 0; i < members.Length; i++) - { - members[i].Init(i); - } - - var pending = new Task[members.Length]; - for (int i = 0; i < members.Length; i++) - { - var config = members[i].Configuration; - config.AbortOnConnectFail = false; - config.HeartbeatConsistencyChecks = true; - pending[i] = ConnectionMultiplexer.ConnectAsync(config, log); - } - - for (int i = 0; i < pending.Length; i++) - { - var muxer = await pending[i].ConfigureAwait(false); - members[i].SetMultiplexer(muxer); - } - - // run initial healthcheck and begin - var result = new MultiGroupMultiplexer(members, options); - await TryHealthCheckAndSelectPreferredGroupAsync(result).ForAwait(); - result.StartPolling(); - return result; - } - - private readonly MultiGroupOptions _options; - private MultiGroupMultiplexer(ConnectionGroupMember[] members, MultiGroupOptions options) - { - _options = options; - _members = members; - _active = null; - } - - internal static async Task TryHealthCheckAndSelectPreferredGroupAsync(object? target) - { - if (target is MultiGroupMultiplexer typed) - { - try - { - if (typed.IsDisposed) return false; - await typed.RunHealthCheckAsync().ForAwait(); - typed.SelectPreferredGroup(); - } - catch (Exception ex) - { - typed.OnInternalError(ex, origin: "update group"); - } - return true; // even if we fault: try again - } - return false; - } - - private void StartPolling() - { - // use a weak-ref to avoid the loop keeping the object alive; capturing the token (rather than - // the muxer) lets us break out of the delay promptly on dispose without resurrecting the target - var cancellationToken = _pollCancellation.Token; - _ = Task.Run(() => PollAsync(new(this), cancellationToken)); - - static async Task PollAsync(WeakReference weakRef, CancellationToken cancellationToken) - { - while (TryGetHealthCheck(weakRef.Target, out var interval)) - { - try - { - await Task.Delay(interval, cancellationToken).ForAwait(); - } - catch (OperationCanceledException) - { - break; // disposed; stop polling without waiting out the interval - } - if (!await TryHealthCheckAndSelectPreferredGroupAsync(weakRef.Target).ForAwait()) break; - } - } - - static bool TryGetHealthCheck(object? target, out TimeSpan interval) - { - if (target is MultiGroupMultiplexer typed - && typed._options.HealthCheck is { } healthCheck) - { - interval = healthCheck.Interval; - return interval > TimeSpan.Zero & interval != TimeSpan.MaxValue; - } - - interval = TimeSpan.Zero; - return false; - } - } - - internal bool IsDisposed => _disposed; - - private Task[]? _reusableHealthCheckBuffer; - - internal async Task RunHealthCheckAsync() - { - if (_disposed) return; - var healthCheck = _options.HealthCheck; - var members = _members; - var pending = HealthCheck.GetReusablePending(ref _reusableHealthCheckBuffer, members.Length); - for (int i = 0; i < members.Length; i++) - { - pending[i] = healthCheck.CheckHealthAsync(members[i].Multiplexer); - } - - await Task.WhenAll(pending).TimeoutAfter(healthCheck.TotalTimeoutMillis()).ForAwait(); - for (int i = 0; i < pending.Length; i++) - { - HealthCheck.HealthCheckResult result; - if (pending[i].IsCompletedSuccessfully) - { - result = await pending[i].ForAwait(); - } - else - { - _ = pending[i].ObserveErrors(); - result = HealthCheck.HealthCheckResult.Unhealthy; - } - - var delta = members[i].UpdateState(result); - if (delta != GroupConnectionChangedEventArgs.ChangeType.Unknown) - { - OnConnectionChanged(delta, members[i]); - } - } - - HealthCheck.PutReusablePending(ref _reusableHealthCheckBuffer, ref pending); - } - - internal void SelectPreferredGroup() - { - if (_disposed) return; - var previousMuxer = _active; - ConnectionGroupMember? preferredMember = null, previousMember = null; - var members = _members; - foreach (var member in members) - { - if (previousMember is null && ReferenceEquals(member.Multiplexer, previousMuxer)) - { - previousMember = member; - } - - if (member.IsConnected) - { - member.UpdateLatency(); // this can change passively - preferredMember = ConnectionGroupMember.Select(preferredMember, member); - } - } - - _active = preferredMember?.Multiplexer; - - if (preferredMember is not null && !ReferenceEquals(preferredMember, previousMember)) - { - OnConnectionChanged( - GroupConnectionChangedEventArgs.ChangeType.ActiveChanged, - preferredMember, - previousMember); - } - } - - private readonly CancellationTokenSource _pollCancellation = new(); - - private List DropAll() - { - _pollCancellation.Cancel(); // stop the polling loop promptly (idempotent) - _active = null; - var members = Interlocked.Exchange(ref _members, []); - if (members.Length is 0) return []; - var muxers = new List(members.Length); - foreach (var member in members) - { - var muxer = member.ClearMultiplexer(); - if (muxer is not null) muxers.Add(muxer); - } - - return muxers; - } - - private bool _disposed; - public void Dispose() - { - _disposed = true; - foreach (var muxer in DropAll()) - { - muxer.Dispose(); - } - } - - public async ValueTask DisposeAsync() - { - _disposed = true; - foreach (var muxer in DropAll()) - { - await muxer.DisposeAsync(); - } - } - - public string ClientName => Active.ClientName; - public string Configuration => Active.Configuration; - public int TimeoutMilliseconds => Active.TimeoutMilliseconds; - - public long OperationCount - { - get - { - long count = 0; - foreach (var member in _members) - { - count += member.Multiplexer.OperationCount; - } - - return count; - } - } - - [Obsolete] - public bool PreserveAsyncOrder - { - get => Active.PreserveAsyncOrder; - set => Active.PreserveAsyncOrder = value; - } - - // Unlike most members, these intentionally do *not* go via Active (which throws when no member is - // available); callers routinely use IsConnected/IsConnecting as a pre-flight check and expect a - // 'false' result - not an exception - when the entire group is down. - public bool IsConnected => _active?.IsConnected ?? false; - public bool IsConnecting => _active?.IsConnecting ?? false; - - [Obsolete] - public bool IncludeDetailInExceptions - { - get => Active.IncludeDetailInExceptions; - set => Active.IncludeDetailInExceptions = value; - } - - public int StormLogThreshold - { - get => Active.StormLogThreshold; - set => Active.StormLogThreshold = value; - } - - private Func? _profilingSessionProvider; - - public void RegisterProfiler(Func profilingSessionProvider) - { - _profilingSessionProvider = profilingSessionProvider; - foreach (var member in _members) - { - member.Multiplexer.RegisterProfiler(profilingSessionProvider); - } - } - - public ServerCounters GetCounters() => Active.GetCounters(); - - private EventHandler? _errorMessage; - - public event EventHandler? ErrorMessage - { - add - { - if (AddHandler(ref _errorMessage, value)) - { - foreach (var member in _members) - { - member.Multiplexer.ErrorMessage += value; - } - } - } - remove - { - if (RemoveHandler(ref _errorMessage, value)) - { - foreach (var member in _members) - { - member.Multiplexer.ErrorMessage -= value; - } - } - } - } - - /// - /// Add a handler, and return true if this is the *first* handler, which means we should subscribe the dependents. - /// - private static bool AddHandler(ref T? field, T? value) where T : Delegate - { - if (value is null) return false; - while (true) // loop until we win (competition) - { - var oldValue = field; - var newValue = oldValue is null ? value : (T)Delegate.Combine(oldValue, value); - - if (ReferenceEquals(Interlocked.CompareExchange(ref field, newValue, oldValue), oldValue)) - { - return oldValue is null; - } - } - } - - /// - /// Remove a handler, and return true if this is the *last* handler, which means we should unsubscribe the dependents. - /// - private static bool RemoveHandler(ref T? field, T? value) where T : Delegate - { - if (value is null) return false; - while (true) // loop until we win (competition) - { - var oldValue = field; - var newValue = oldValue is null ? null : (T?)Delegate.Remove(oldValue, value); - - if (ReferenceEquals(Interlocked.CompareExchange(ref field, newValue, oldValue), oldValue)) - { - return newValue is null; - } - } - } - - /// - /// Subscribe a child multiplexer to all local event handlers that have subscribers. - /// - private void AddEventHandlers(ConnectionMultiplexer muxer) - { - muxer.ErrorMessage += _errorMessage; - muxer.ConnectionFailed += _connectionFailed; - muxer.InternalError += _internalError; - muxer.ConnectionRestored += _connectionRestored; - muxer.ConfigurationChanged += _configurationChanged; - muxer.ConfigurationChangedBroadcast += _configurationChangedBroadcast; - muxer.ServerMaintenanceEvent += _serverMaintenanceEvent; - muxer.HashSlotMoved += _hashSlotMoved; - } - - /// - /// Unsubscribe a child multiplexer from all local event handlers. - /// - private void RemoveEventHandlers(ConnectionMultiplexer? muxer) - { - if (muxer is null) return; - muxer.ErrorMessage -= _errorMessage; - muxer.ConnectionFailed -= _connectionFailed; - muxer.InternalError -= _internalError; - muxer.ConnectionRestored -= _connectionRestored; - muxer.ConfigurationChanged -= _configurationChanged; - muxer.ConfigurationChangedBroadcast -= _configurationChangedBroadcast; - muxer.ServerMaintenanceEvent -= _serverMaintenanceEvent; - muxer.HashSlotMoved -= _hashSlotMoved; - } - - private EventHandler? _connectionFailed; - - public event EventHandler? ConnectionFailed - { - add - { - if (AddHandler(ref _connectionFailed, value)) - { - foreach (var member in _members) - { - member.Multiplexer.ConnectionFailed += value; - } - } - } - remove - { - if (RemoveHandler(ref _connectionFailed, value)) - { - foreach (var member in _members) - { - member.Multiplexer.ConnectionFailed -= value; - } - } - } - } - - private EventHandler? _internalError; - - public event EventHandler? InternalError - { - add - { - if (AddHandler(ref _internalError, value)) - { - foreach (var member in _members) - { - member.Multiplexer.InternalError += value; - } - } - } - remove - { - if (RemoveHandler(ref _internalError, value)) - { - foreach (var member in _members) - { - member.Multiplexer.InternalError -= value; - } - } - } - } - - private EventHandler? _connectionRestored; - - public event EventHandler? ConnectionRestored - { - add - { - if (AddHandler(ref _connectionRestored, value)) - { - foreach (var member in _members) - { - member.Multiplexer.ConnectionRestored += value; - } - } - } - remove - { - if (RemoveHandler(ref _connectionRestored, value)) - { - foreach (var member in _members) - { - member.Multiplexer.ConnectionRestored -= value; - } - } - } - } - - private EventHandler? _configurationChanged; - - public event EventHandler? ConfigurationChanged - { - add - { - if (AddHandler(ref _configurationChanged, value)) - { - foreach (var member in _members) - { - member.Multiplexer.ConfigurationChanged += value; - } - } - } - remove - { - if (RemoveHandler(ref _configurationChanged, value)) - { - foreach (var member in _members) - { - member.Multiplexer.ConfigurationChanged -= value; - } - } - } - } - - private EventHandler? _configurationChangedBroadcast; - - public event EventHandler? ConfigurationChangedBroadcast - { - add - { - if (AddHandler(ref _configurationChangedBroadcast, value)) - { - foreach (var member in _members) - { - member.Multiplexer.ConfigurationChangedBroadcast += value; - } - } - } - remove - { - if (RemoveHandler(ref _configurationChangedBroadcast, value)) - { - foreach (var member in _members) - { - member.Multiplexer.ConfigurationChangedBroadcast -= value; - } - } - } - } - - private EventHandler? _serverMaintenanceEvent; - - public event EventHandler? ServerMaintenanceEvent - { - add - { - if (AddHandler(ref _serverMaintenanceEvent, value)) - { - foreach (var member in _members) - { - member.Multiplexer.ServerMaintenanceEvent += value; - } - } - } - remove - { - if (RemoveHandler(ref _serverMaintenanceEvent, value)) - { - foreach (var member in _members) - { - member.Multiplexer.ServerMaintenanceEvent -= value; - } - } - } - } - - public EndPoint[] GetEndPoints(bool configuredOnly = false) => Active.GetEndPoints(configuredOnly); - - public void Wait(Task task) => Active.Wait(task); - - public T Wait(Task task) => Active.Wait(task); - - public void WaitAll(params Task[] tasks) => Active.WaitAll(tasks); - - private EventHandler? _hashSlotMoved; - - public event EventHandler? HashSlotMoved - { - add - { - if (AddHandler(ref _hashSlotMoved, value)) - { - foreach (var member in _members) - { - member.Multiplexer.HashSlotMoved += value; - } - } - } - remove - { - if (RemoveHandler(ref _hashSlotMoved, value)) - { - foreach (var member in _members) - { - member.Multiplexer.HashSlotMoved -= value; - } - } - } - } - - public int HashSlot(RedisKey key) => Active.HashSlot(key); - - private ISubscriber? _defaultSubscriber; - - public ISubscriber GetSubscriber(object? asyncState = null) - { - if (asyncState is null) return _defaultSubscriber ??= new MultiGroupSubscriber(this, null); - return new MultiGroupSubscriber(this, asyncState); - } - - public IDatabase GetDatabase(int db = -1, object? asyncState = null) - { - if (asyncState is null & db >= -1 & db <= ConnectionMultiplexer.MaxCachedDatabaseInstance) - { - return _databases[db + 1] ??= new MultiGroupDatabase(this, db, null); - } - - return new MultiGroupDatabase(this, db, asyncState); - } - - private readonly IDatabase?[] _databases = new IDatabase?[ConnectionMultiplexer.MaxCachedDatabaseInstance + 2]; - - public IServer GetServer(string host, int port, object? asyncState = null) - { - Exception ex; - try - { - // try "active" first, and preserve the exception - return Active.GetServer(host, port, asyncState); - } - catch (Exception e) - { - ex = e; - } - - foreach (var member in _members) - { - try - { - return member.Multiplexer.GetServer(host, port, asyncState); - } - catch (Exception e) { Debug.WriteLine(e.Message); } - } - - throw ex; - } - - public IServer GetServer(string hostAndPort, object? asyncState = null) - { - Exception ex; - try - { - // try "active" first, and preserve the exception - return Active.GetServer(hostAndPort, asyncState); - } - catch (Exception e) - { - ex = e; - } - - foreach (var member in _members) - { - try - { - return member.Multiplexer.GetServer(hostAndPort, asyncState); - } - catch (Exception e) { Debug.WriteLine(e.Message); } - } - - throw ex; - } - - public IServer GetServer(IPAddress host, int port) - { - Exception ex; - try - { - // try "active" first, and preserve the exception - return Active.GetServer(host, port); - } - catch (Exception e) - { - ex = e; - } - - foreach (var member in _members) - { - try - { - return member.Multiplexer.GetServer(host, port); - } - catch (Exception e) { Debug.WriteLine(e.Message); } - } - - throw ex; - } - - public IServer GetServer(EndPoint endpoint, object? asyncState = null) - { - Exception ex; - try - { - // try "active" first, and preserve the exception - return Active.GetServer(endpoint, asyncState); - } - catch (Exception e) - { - ex = e; - } - - foreach (var member in _members) - { - try - { - return member.Multiplexer.GetServer(endpoint, asyncState); - } - catch (Exception e) { Debug.WriteLine(e.Message); } - } - - throw ex; - } - - public IServer GetServer(RedisKey key, object? asyncState = null, CommandFlags flags = CommandFlags.None) => - Active.GetServer(key, asyncState, flags); - - public IServer[] GetServers() => Active.GetServers(); - - public Task ConfigureAsync(TextWriter? log = null) => Active.ConfigureAsync(log); - - public bool Configure(TextWriter? log = null) => Active.Configure(log); - - public string GetStatus() => Active.GetStatus(); - - public void GetStatus(TextWriter log) => Active.GetStatus(log); - - public void Close(bool allowCommandsToComplete = true) - { - _disposed = true; - foreach (var member in DropAll()) - { - member.Close(allowCommandsToComplete); - } - } - - public async Task CloseAsync(bool allowCommandsToComplete = true) - { - _disposed = true; - foreach (var member in DropAll()) - { - await member.CloseAsync(allowCommandsToComplete); - } - } - - public string? GetStormLog() => Active.GetStormLog(); - - public void ResetStormLog() => Active.ResetStormLog(); - - public long PublishReconfigure(CommandFlags flags = CommandFlags.None) => Active.PublishReconfigure(flags); - - public Task PublishReconfigureAsync(CommandFlags flags = CommandFlags.None) => - Active.PublishReconfigureAsync(flags); - - public int GetHashSlot(RedisKey key) => Active.GetHashSlot(key); - - public void ExportConfiguration(Stream destination, ExportOptions options = ExportOptions.All) => - Active.ExportConfiguration(destination, options); - - private readonly HashSet _suffixes = new(); // in case we need to add to a new muxer - public void AddLibraryNameSuffix(string suffix) - { - if (string.IsNullOrWhiteSpace(suffix)) return; // trivial - bool isNew; - lock (_suffixes) - { - isNew = _suffixes.Add(suffix); - } - - if (isNew) - { - foreach (var member in _members) - { - member.Multiplexer.AddLibraryNameSuffix(suffix); - } - } - } - - public event EventHandler? ConnectionChanged; - - private void OnConnectionChanged( - GroupConnectionChangedEventArgs.ChangeType changeType, - ConnectionGroupMember member, - ConnectionGroupMember? previousMember = null) - { - var handler = ConnectionChanged; - if (handler is not null) - { - new GroupConnectionChangedEventArgs(changeType, member, previousMember) - .CompleteAsWorker(handler, this); - } - } - - public async Task AddAsync(ConnectionGroupMember member, TextWriter? log = null) - { - // connect - member.Init(_members.Length); - member.Configuration.HeartbeatConsistencyChecks = true; - var muxer = await ConnectionMultiplexer.ConnectAsync(member.Configuration, log).ConfigureAwait(false); - member.SetMultiplexer(muxer); - var health = await _options.HealthCheck.CheckHealthAsync(muxer).ConfigureAwait(false); - member.UpdateState(health); - - // apply any shared hooks - AddEventHandlers(muxer); - if (_profilingSessionProvider is not null) muxer.RegisterProfiler(_profilingSessionProvider); - lock (_suffixes) - { - foreach (var suffix in _suffixes) - { - muxer.AddLibraryNameSuffix(suffix); - } - } - - // update the members array - while (true) - { - var arr = _members; - var newArr = new ConnectionGroupMember[arr.Length + 1]; - Array.Copy(arr, 0, newArr, 0, arr.Length); - newArr[arr.Length] = member; - if (Interlocked.CompareExchange(ref _members, newArr, arr) == arr) break; - } - - OnConnectionChanged(GroupConnectionChangedEventArgs.ChangeType.Added, member); - SelectPreferredGroup(); - - // pub/sub - await AddPubSubHandlersAsync(member).ConfigureAwait(false); - } - - public bool TryFailoverTo(ConnectionGroupMember? member) - { - if (member is null) - { - // remove any explicit overrides, returning whether that was an actual change - bool result = false; - foreach (var m in _members) - { - if (m.ExplicitOverride) - { - result = true; // someone was explicitly enabled - m.ExplicitOverride = false; - } - } - SelectPreferredGroup(); - return result; - } - - var members = _members; - if (!members.Contains(member)) - { - // not one of ours? - return false; - } - - if (!member.IsConnected) - { - // not allowed - return false; - } - - if (member.ExplicitOverride) - { - // already preferred; no change, but report as success - return true; - } - - // otherwise, deselect everyone else, and select this one - foreach (var m in members) - { - m.ExplicitOverride = ReferenceEquals(m, member); - } - SelectPreferredGroup(); - return true; - } - - public bool Remove(ConnectionGroupMember member) - { - while (true) - { - var arr = _members; - int index = -1; - for (int i = 0; i < arr.Length; i++) - { - if (ReferenceEquals(arr[i], member)) - { - index = i; - break; - } - } - - if (index == -1) return false; - var newArr = new ConnectionGroupMember[arr.Length - 1]; - if (index > 0) Array.Copy(arr, 0, newArr, 0, index); - if (index < newArr.Length) Array.Copy(arr, index + 1, newArr, index, newArr.Length - index); - if (Interlocked.CompareExchange(ref _members, newArr, arr) == arr) break; - } - - var muxer = member.ClearMultiplexer(); - RemoveEventHandlers(muxer); - OnConnectionChanged(GroupConnectionChangedEventArgs.ChangeType.Removed, member); - SelectPreferredGroup(); - muxer?.Dispose(); - return true; - } - - internal void OnHeartbeat() // for testing, to update latency etc - { - foreach (var member in _members) - { - member.Multiplexer.OnHeartbeat(); - } - } - - internal void OnInternalError(Exception exception, EndPoint? endpoint = null, ConnectionType connectionType = ConnectionType.None, string? origin = null) - { - var handler = _internalError; - if (handler is not null) - { - InternalErrorEventArgs args = new(handler, this, endpoint, connectionType, exception, origin); - ConnectionMultiplexer.CompleteAsWorker(args); - } - } -} diff --git a/src/StackExchange.Redis/PhysicalBridge.cs b/src/StackExchange.Redis/PhysicalBridge.cs index 21047efa22..de9edb7de6 100644 --- a/src/StackExchange.Redis/PhysicalBridge.cs +++ b/src/StackExchange.Redis/PhysicalBridge.cs @@ -133,7 +133,7 @@ public void Dispose() { isDisposed = true; // If there's anything in the backlog and we're being torn down - exfil it immediately (e.g. so all awaitables complete) - AbandonPendingBacklog(new ObjectDisposedException("Connection is being disposed")); + AbandonPendingBacklog(new ObjectDisposedException("Connection is being disposed"), null); try { _backlogAutoReset?.Set(); @@ -152,7 +152,7 @@ public void Dispose() isDisposed = true; // make damn sure we don't true to resurrect // If there's anything in the backlog and we're being torn down - exfil it immediately (e.g. so all awaitables complete) - AbandonPendingBacklog(new ObjectDisposedException("Connection is being finalized")); + AbandonPendingBacklog(new ObjectDisposedException("Connection is being finalized"), null); // shouldn't *really* touch managed objects // in a finalizer, but we need to kill that socket, @@ -192,7 +192,7 @@ private WriteResult QueueOrFailMessage(Message message) // Anything else goes in the bin - we're just not ready for you yet message.Cancel(); Multiplexer.OnMessageFaulted(message, null); - message.Complete(); + message.Complete(null); return WriteResult.NoConnectionAvailable; } @@ -200,7 +200,7 @@ private WriteResult FailDueToNoConnection(Message message) { message.Cancel(); Multiplexer.OnMessageFaulted(message, null); - message.Complete(); + message.Complete(null); return WriteResult.NoConnectionAvailable; } @@ -462,7 +462,7 @@ internal void OnConnectionFailed(PhysicalConnection connection, ConnectionFailur // If we're configured to, fail all pending backlogged messages if (Multiplexer.RawConfig.BacklogPolicy?.AbortPendingOnConnectionFailure == true) { - AbandonPendingBacklog(innerException); + AbandonPendingBacklog(innerException, connection); } if (reportNextFailure) @@ -511,12 +511,12 @@ internal void OnDisconnected(ConnectionFailureType failureType, PhysicalConnecti } } - private void AbandonPendingBacklog(Exception ex) + private void AbandonPendingBacklog(Exception ex, PhysicalConnection? connection) { while (BacklogTryDequeue(out Message? next)) { Multiplexer.OnMessageFaulted(next, ex); - next.SetExceptionAndComplete(ex, this); + next.SetExceptionAndComplete(ex, connection); } } @@ -776,7 +776,7 @@ private WriteResult WriteMessageInsideLock(PhysicalConnection physical, Message // killed the underlying connection Trace("Unable to write to server"); message.Fail(ConnectionFailureType.ProtocolFailure, null, "failure before write: " + result.ToString(), Multiplexer); - message.Complete(); + message.Complete(physical); return result; } // The parent message (next) may be returned from GetMessages @@ -996,7 +996,7 @@ private void CheckBacklogForTimeouts() // Tell the message it has failed // Note: Attempting to *avoid* reentrancy/deadlock issues by not holding the lock while completing messages. var ex = Multiplexer.GetException(WriteResult.TimeoutBeforeWrite, message, ServerEndPoint, this); - message.SetExceptionAndComplete(ex, this); + message.SetExceptionAndComplete(ex, null); } } } @@ -1072,7 +1072,7 @@ private void ProcessBacklog() } var ex = ExceptionFactory.Timeout(Multiplexer, "The message was in the backlog when connection was disposed", message, ServerEndPoint, WriteResult.TimeoutBeforeWrite, this); - message.SetExceptionAndComplete(ex, this); + message.SetExceptionAndComplete(ex, null); } } } @@ -1222,7 +1222,7 @@ private WriteResult TimedOutBeforeWrite(Message message) { message.Cancel(); Multiplexer.OnMessageFaulted(message, null); - message.Complete(); + message.Complete(null); return WriteResult.TimeoutBeforeWrite; } @@ -1355,7 +1355,7 @@ private async ValueTask CompleteWriteAndReleaseLockAsync( private WriteResult HandleWriteException(PhysicalConnection? physical, Message message, Exception ex) { var inner = new RedisConnectionException(ConnectionFailureType.InternalFailure, "Failed to write", ex); - message.SetExceptionAndComplete(inner, this); + message.SetExceptionAndComplete(inner, physical); // Tear down the physical connection. A write that throws may have left a partial frame on the // wire, and continuing to use the same socket would let the next reply match the wrong message // in the response queue. Forcing a reconnect drains the in-flight queue with failures and @@ -1588,7 +1588,7 @@ private WriteResult WriteMessageToServerInsideWriteLock(PhysicalConnection conne { Trace("Write failed: " + ex.Message); message.Fail(ConnectionFailureType.InternalFailure, ex, null, Multiplexer); - message.Complete(); + message.Complete(connection); // This failed without actually writing; we're OK with that... unless there's a transaction if (connection?.TransactionActive == true) @@ -1603,7 +1603,7 @@ private WriteResult WriteMessageToServerInsideWriteLock(PhysicalConnection conne { Trace("Write failed: " + ex.Message); message.Fail(ConnectionFailureType.InternalFailure, ex, null, Multiplexer); - message.Complete(); + message.Complete(connection); // We're not sure *what* happened here - probably an IOException; kill the connection connection?.RecordConnectionFailed(ConnectionFailureType.InternalFailure, ex); diff --git a/src/StackExchange.Redis/PhysicalConnection.Read.cs b/src/StackExchange.Redis/PhysicalConnection.Read.cs index 0de5487cd0..ad6973eca5 100644 --- a/src/StackExchange.Redis/PhysicalConnection.Read.cs +++ b/src/StackExchange.Redis/PhysicalConnection.Read.cs @@ -632,7 +632,7 @@ private void MatchNextResult(ReadOnlySpan frame) if (_awaitingToken is not null && (msg = Interlocked.Exchange(ref _awaitingToken, null)) is not null) { _readStatus = ReadStatus.ResponseSequenceCheck; - if (!ProcessHighIntegrityResponseToken(msg, frame, BridgeCouldBeNull)) + if (!ProcessHighIntegrityResponseToken(msg, frame, this)) { RecordConnectionFailed(ConnectionFailureType.ResponseIntegrityFailure, origin: nameof(ReadStatus.ResponseSequenceCheck)); } @@ -678,7 +678,7 @@ static void Throw(ReadOnlySpan frame, ConnectionType connection, RedisProt if (highIntegrityToken is 0) { // can't complete yet if needs checksum - msg.Complete(); + msg.Complete(this); } } else @@ -694,7 +694,7 @@ static void Throw(ReadOnlySpan frame, ConnectionType connection, RedisProt _readStatus = ReadStatus.MatchResultComplete; _activeMessage = null; - static bool ProcessHighIntegrityResponseToken(Message message, ReadOnlySpan frame, PhysicalBridge? bridge) + static bool ProcessHighIntegrityResponseToken(Message message, ReadOnlySpan frame, PhysicalConnection? connection) { bool isValid = false; var reader = new RespReader(frame); @@ -716,12 +716,12 @@ static bool ProcessHighIntegrityResponseToken(Message message, ReadOnlySpan queue, [NotNullWhen(true)] out Messa } } - private void RecordMessageFailed(Message next, Exception? ex, string? origin, PhysicalBridge? bridge) + private void RecordMessageFailed(Message next, Exception? ex, string? origin, PhysicalConnection? connection) { if (next.Command == RedisCommand.QUIT && next.TrySetResult(true)) { // fine, death of a socket is close enough - next.Complete(); + next.Complete(this); } else { - if (bridge != null) + var bridge = connection?.BridgeCouldBeNull; + if (bridge is not null) { bridge.Trace("Failing: " + next); bridge.Multiplexer?.OnMessageFaulted(next, ex, origin); } - next.SetExceptionAndComplete(ex!, bridge); + next.SetExceptionAndComplete(ex!, connection); } } @@ -594,7 +597,7 @@ internal void EnqueueInsideWriteLock(Message next, bool enforceMuxer = true) // we can still process it to avoid making things worse/more complex, // but: we can't reliably assume this works, so: shout now! next.Cancel(); - next.Complete(); + next.Complete(null); } bool wasEmpty; @@ -741,7 +744,7 @@ internal void OnBridgeHeartbeat(out int asyncTimeoutDetected, out int syncTimeou lock (_writtenAwaitingResponse) { - if (_writtenAwaitingResponse.Count != 0 && BridgeCouldBeNull is PhysicalBridge bridge) + if (_writtenAwaitingResponse.Count != 0 && BridgeCouldBeNull is { } bridge) { var server = bridge.ServerEndPoint; var multiplexer = bridge.Multiplexer; @@ -760,7 +763,7 @@ internal void OnBridgeHeartbeat(out int asyncTimeoutDetected, out int syncTimeou : $"Timeout awaiting response ({elapsed}ms elapsed, timeout is {timeout}ms)"; var timeoutEx = ExceptionFactory.Timeout(multiplexer, baseErrorMessage, msg, server); multiplexer.OnMessageFaulted(msg, timeoutEx); - msg.SetExceptionAndComplete(timeoutEx, bridge); // tell the message that it is doomed + msg.SetExceptionAndComplete(timeoutEx, this); // tell the message that it is doomed multiplexer.OnAsyncTimeout(); asyncTimeoutDetected++; } @@ -790,6 +793,14 @@ internal void OnBridgeHeartbeat(out int asyncTimeoutDetected, out int syncTimeou } } } + + // backstop for a tripped circuit-breaker: normally the trip worker actuates the teardown, but + // if it hasn't been scheduled yet (and the connection has gone quiet) the heartbeat does it. + // Must be outside the lock above - RecordConnectionFailed takes that same lock. + if (Volatile.Read(ref _circuitBreakerState) is CircuitBreakerTripped) + { + CheckCircuitBreakerTrip(); + } } internal void OnInternalError(Exception exception, [CallerMemberName] string? origin = null) @@ -1165,6 +1176,53 @@ internal bool HasPendingCallerFacingItems() if (lockTaken) Monitor.Exit(_writtenAwaitingResponse); } } + + public void ObserveMessageResult(Exception? fault) + { + // Hot path - runs per completed message. Let the breaker observe the outcome (ObserveResult + // returns true while healthy); if it trips and we're the *first* to notice, hand off the + // teardown rather than doing it inline: RecordConnectionFailed can fail the whole backlog and + // build a detailed exception, which we don't want to pay for on the completion thread. + if (circuitBreaker is { } cb && !cb.ObserveResult(fault) + && Interlocked.CompareExchange(ref _circuitBreakerState, CircuitBreakerTripped, CircuitBreakerHealthy) is CircuitBreakerHealthy) + { + // hand off to a worker; the heartbeat (see OnBridgeHeartbeat) is a backstop in case the + // pool is slow to schedule us and the connection then goes quiet - either way the actual + // teardown happens exactly once, via CheckCircuitBreakerTrip. A cached static callback with + // the connection as state avoids any per-trip delegate/closure allocation. + ThreadPool.QueueUserWorkItem(s_CheckCircuitBreakerTrip, this); + } + } + + private static readonly WaitCallback s_CheckCircuitBreakerTrip = static state => + { + var connection = (PhysicalConnection)state!; + try + { + connection.CheckCircuitBreakerTrip(); + } + catch (Exception ex) + { + connection.OnInternalError(ex); + } + }; + + // Actuates a pending circuit-breaker teardown at most once. Called from both the trip worker and + // the heartbeat backstop; whoever wins the tripped->actuated transition does the work. Routing via + // RecordConnectionFailed (not a bare Shutdown) is what surfaces the failure as a ConnectionFailed + // event, which is what Active:Active reacts to when re-routing away from this member. + private void CheckCircuitBreakerTrip() + { + if (Interlocked.CompareExchange(ref _circuitBreakerState, CircuitBreakerActuated, CircuitBreakerTripped) is CircuitBreakerTripped) + { + RecordConnectionFailed(ConnectionFailureType.CircuitBreaker); + } + } + + private CircuitBreaker.Accumulator? circuitBreaker; + + private int _circuitBreakerState; // transitions strictly Healthy -> Tripped -> Actuated + private const int CircuitBreakerHealthy = 0, CircuitBreakerTripped = 1, CircuitBreakerActuated = 2; } internal sealed class DummyHighIntegrityMessage : Message diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index e8acf4e023..b1d16d31f1 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,87 +1,157 @@ #nullable enable -[SER007]abstract StackExchange.Redis.HealthCheck.HealthCheckProbe.CheckHealthAsync(StackExchange.Redis.HealthCheck! healthCheck, StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! -[SER007]abstract StackExchange.Redis.HealthCheck.HealthCheckProbePolicy.Evaluate(in StackExchange.Redis.HealthCheck.HealthCheckProbeContext context) -> StackExchange.Redis.HealthCheck.HealthCheckResult -[SER007]abstract StackExchange.Redis.HealthCheck.KeyWriteHealthCheckProbe.CheckHealthAsync(StackExchange.Redis.HealthCheck! healthCheck, StackExchange.Redis.IDatabaseAsync! database, StackExchange.Redis.RedisKey key) -> System.Threading.Tasks.Task! -[SER007]override StackExchange.Redis.HealthCheck.HealthCheckProbeContext.ToString() -> string! -[SER007]override StackExchange.Redis.HealthCheck.KeyWriteHealthCheckProbe.CheckHealthAsync(StackExchange.Redis.HealthCheck! healthCheck, StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! -[SER007]StackExchange.Redis.HealthCheck -[SER007]StackExchange.Redis.HealthCheck.CheckHealthAsync(StackExchange.Redis.IConnectionMultiplexer! multiplexer) -> System.Threading.Tasks.Task! -[SER007]StackExchange.Redis.HealthCheck.CheckHealthAsync(StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! -[SER007]StackExchange.Redis.HealthCheck.Clone() -> StackExchange.Redis.HealthCheck! -[SER007]StackExchange.Redis.HealthCheck.HealthCheck() -> void -[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbe -[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbe.HealthCheckProbe() -> void -[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbeContext -[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbeContext.Failure.get -> int -[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbeContext.HealthCheckProbeContext() -> void -[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbeContext.HealthCheckProbeContext(StackExchange.Redis.HealthCheck.HealthCheckResult result, int success, int failure, int remaining, System.TimeSpan probeInterval) -> void -[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbeContext.ProbeInterval.get -> System.TimeSpan -[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbeContext.Remaining.get -> int -[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbeContext.Result.get -> StackExchange.Redis.HealthCheck.HealthCheckResult -[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbeContext.Success.get -> int -[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbePolicy -[SER007]StackExchange.Redis.HealthCheck.HealthCheckProbePolicy.HealthCheckProbePolicy() -> void -[SER007]StackExchange.Redis.HealthCheck.HealthCheckResult -[SER007]StackExchange.Redis.HealthCheck.HealthCheckResult.Healthy = 1 -> StackExchange.Redis.HealthCheck.HealthCheckResult -[SER007]StackExchange.Redis.HealthCheck.HealthCheckResult.Inconclusive = 0 -> StackExchange.Redis.HealthCheck.HealthCheckResult -[SER007]StackExchange.Redis.HealthCheck.HealthCheckResult.Unhealthy = 2 -> StackExchange.Redis.HealthCheck.HealthCheckResult -[SER007]StackExchange.Redis.HealthCheck.Interval.get -> System.TimeSpan -[SER007]StackExchange.Redis.HealthCheck.Interval.set -> void -[SER007]StackExchange.Redis.HealthCheck.KeyWriteHealthCheckProbe -[SER007]StackExchange.Redis.HealthCheck.KeyWriteHealthCheckProbe.KeyWriteHealthCheckProbe() -> void -[SER007]StackExchange.Redis.HealthCheck.Probe.get -> StackExchange.Redis.HealthCheck.HealthCheckProbe! -[SER007]StackExchange.Redis.HealthCheck.Probe.set -> void -[SER007]StackExchange.Redis.HealthCheck.ProbeCount.get -> int -[SER007]StackExchange.Redis.HealthCheck.ProbeCount.set -> void -[SER007]StackExchange.Redis.HealthCheck.ProbeInterval.get -> System.TimeSpan -[SER007]StackExchange.Redis.HealthCheck.ProbeInterval.set -> void -[SER007]StackExchange.Redis.HealthCheck.ProbePolicy.get -> StackExchange.Redis.HealthCheck.HealthCheckProbePolicy! -[SER007]StackExchange.Redis.HealthCheck.ProbePolicy.set -> void -[SER007]StackExchange.Redis.HealthCheck.ProbeTimeout.get -> System.TimeSpan -[SER007]StackExchange.Redis.HealthCheck.ProbeTimeout.set -> void +abstract StackExchange.Redis.Configuration.LoggingTunnel.Log(System.IO.Stream! stream, System.Net.EndPoint! endpoint, StackExchange.Redis.ConnectionType connectionType) -> System.IO.Stream! +override StackExchange.Redis.Configuration.LoggingTunnel.BeforeAuthenticateAsync(System.Net.EndPoint! endpoint, StackExchange.Redis.ConnectionType connectionType, System.Net.Sockets.Socket? socket, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +override StackExchange.Redis.Configuration.LoggingTunnel.BeforeSocketConnectAsync(System.Net.EndPoint! endPoint, StackExchange.Redis.ConnectionType connectionType, System.Net.Sockets.Socket? socket, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +override StackExchange.Redis.Configuration.LoggingTunnel.GetSocketConnectEndpointAsync(System.Net.EndPoint! endpoint, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.BeginRead(byte[]! buffer, int offset, int count, System.AsyncCallback? callback, object? state) -> System.IAsyncResult! +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.BeginWrite(byte[]! buffer, int offset, int count, System.AsyncCallback? callback, object? state) -> System.IAsyncResult! +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.CanRead.get -> bool +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.CanSeek.get -> bool +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.CanTimeout.get -> bool +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.CanWrite.get -> bool +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Close() -> void +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.EndRead(System.IAsyncResult! asyncResult) -> int +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.EndWrite(System.IAsyncResult! asyncResult) -> void +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Flush() -> void +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.FlushAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Length.get -> long +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Position.get -> long +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Position.set -> void +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Read(byte[]! buffer, int offset, int count) -> int +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.ReadAsync(byte[]! buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.ReadByte() -> int +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.ReadTimeout.get -> int +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.ReadTimeout.set -> void +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Seek(long offset, System.IO.SeekOrigin origin) -> long +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.SetLength(long value) -> void +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Write(byte[]! buffer, int offset, int count) -> void +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.WriteAsync(byte[]! buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.WriteByte(byte value) -> void +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.WriteTimeout.get -> int +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.WriteTimeout.set -> void +StackExchange.Redis.Configuration.LoggingTunnel +StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream +StackExchange.Redis.Configuration.LoggingTunnel.LoggingTunnel(StackExchange.Redis.ConfigurationOptions? options = null, StackExchange.Redis.Configuration.Tunnel? tail = null) -> void StackExchange.Redis.IServer.InventKey(StackExchange.Redis.RedisKey prefix = default(StackExchange.Redis.RedisKey)) -> StackExchange.Redis.RedisKey -[SER007]static StackExchange.Redis.HealthCheck.Default.get -> StackExchange.Redis.HealthCheck! -[SER007]static StackExchange.Redis.HealthCheck.HealthCheckProbe.HealthyTask.get -> System.Threading.Tasks.Task! -[SER007]static StackExchange.Redis.HealthCheck.HealthCheckProbe.InconclusiveTask.get -> System.Threading.Tasks.Task! -[SER007]static StackExchange.Redis.HealthCheck.HealthCheckProbe.IsConnected.get -> StackExchange.Redis.HealthCheck.HealthCheckProbe! -[SER007]static StackExchange.Redis.HealthCheck.HealthCheckProbe.Ping.get -> StackExchange.Redis.HealthCheck.HealthCheckProbe! -[SER007]static StackExchange.Redis.HealthCheck.HealthCheckProbe.StringSet.get -> StackExchange.Redis.HealthCheck.HealthCheckProbe! -[SER007]static StackExchange.Redis.HealthCheck.HealthCheckProbe.UnhealthyTask.get -> System.Threading.Tasks.Task! -[SER007]static StackExchange.Redis.HealthCheck.HealthCheckProbePolicy.AllSuccess.get -> StackExchange.Redis.HealthCheck.HealthCheckProbePolicy! -[SER007]static StackExchange.Redis.HealthCheck.HealthCheckProbePolicy.AnySuccess.get -> StackExchange.Redis.HealthCheck.HealthCheckProbePolicy! -[SER007]static StackExchange.Redis.HealthCheck.HealthCheckProbePolicy.MajoritySuccess.get -> StackExchange.Redis.HealthCheck.HealthCheckProbePolicy! -[SER007]override StackExchange.Redis.ConnectionGroupMember.ToString() -> string! -[SER007]StackExchange.Redis.ConnectionGroupMember -[SER007]StackExchange.Redis.ConnectionGroupMember.ConnectionGroupMember(StackExchange.Redis.ConfigurationOptions! configuration, string! name = "") -> void -[SER007]StackExchange.Redis.ConnectionGroupMember.ConnectionGroupMember(string! configuration, string! name = "") -> void -[SER007]StackExchange.Redis.ConnectionGroupMember.IsConnected.get -> bool -[SER007]StackExchange.Redis.ConnectionGroupMember.Latency.get -> System.TimeSpan -[SER007]StackExchange.Redis.ConnectionGroupMember.Name.get -> string! -[SER007]StackExchange.Redis.ConnectionGroupMember.Weight.get -> double -[SER007]StackExchange.Redis.ConnectionGroupMember.Weight.set -> void -[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs -[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType -[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType.ActiveChanged = 5 -> StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType -[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType.Added = 1 -> StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType -[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType.Disconnected = 3 -> StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType -[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType.Reconnected = 4 -> StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType -[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType.Removed = 2 -> StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType -[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType.Unknown = 0 -> StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType -[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs.Group.get -> StackExchange.Redis.ConnectionGroupMember! -[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs.GroupConnectionChangedEventArgs(StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType type, StackExchange.Redis.ConnectionGroupMember! group, StackExchange.Redis.ConnectionGroupMember? previousGroup = null) -> void -[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs.PreviousGroup.get -> StackExchange.Redis.ConnectionGroupMember? -[SER007]StackExchange.Redis.GroupConnectionChangedEventArgs.Type.get -> StackExchange.Redis.GroupConnectionChangedEventArgs.ChangeType -[SER007]StackExchange.Redis.IConnectionGroup -[SER007]StackExchange.Redis.IConnectionGroup.ActiveMember.get -> StackExchange.Redis.ConnectionGroupMember? -[SER007]StackExchange.Redis.IConnectionGroup.AddAsync(StackExchange.Redis.ConnectionGroupMember! member, System.IO.TextWriter? log = null) -> System.Threading.Tasks.Task! -[SER007]StackExchange.Redis.IConnectionGroup.ConnectionChanged -> System.EventHandler? -[SER007]StackExchange.Redis.IConnectionGroup.GetMembers() -> System.ReadOnlySpan -[SER007]StackExchange.Redis.IConnectionGroup.Remove(StackExchange.Redis.ConnectionGroupMember! member) -> bool -[SER007]StackExchange.Redis.IConnectionGroup.TryFailoverTo(StackExchange.Redis.ConnectionGroupMember? member) -> bool -[SER007]StackExchange.Redis.MultiGroupOptions -[SER007]StackExchange.Redis.MultiGroupOptions.HealthCheck.get -> StackExchange.Redis.HealthCheck! -[SER007]StackExchange.Redis.MultiGroupOptions.HealthCheck.set -> void -[SER007]StackExchange.Redis.MultiGroupOptions.MultiGroupOptions() -> void -[SER007]static StackExchange.Redis.ConnectionMultiplexer.ConnectGroupAsync(StackExchange.Redis.ConnectionGroupMember! member0, StackExchange.Redis.ConnectionGroupMember! member1, StackExchange.Redis.MultiGroupOptions? options = null, System.IO.TextWriter? log = null) -> System.Threading.Tasks.Task! -[SER007]static StackExchange.Redis.ConnectionMultiplexer.ConnectGroupAsync(StackExchange.Redis.ConnectionGroupMember![]! members, StackExchange.Redis.MultiGroupOptions? options = null, System.IO.TextWriter? log = null) -> System.Threading.Tasks.Task! -[SER007]static StackExchange.Redis.MultiGroupOptions.Default.get -> StackExchange.Redis.MultiGroupOptions! +static StackExchange.Redis.Configuration.LoggingTunnel.DefaultFormatCommand(StackExchange.Redis.RedisResult! value) -> string! +static StackExchange.Redis.Configuration.LoggingTunnel.DefaultFormatResponse(StackExchange.Redis.RedisResult! value) -> string! +static StackExchange.Redis.Configuration.LoggingTunnel.LogToDirectory(StackExchange.Redis.ConfigurationOptions! options, string! path) -> void +static StackExchange.Redis.Configuration.LoggingTunnel.ReplayAsync(string! path, System.Action! pair) -> System.Threading.Tasks.Task! +static StackExchange.Redis.Configuration.LoggingTunnel.ReplayAsync(System.IO.Stream! out, System.IO.Stream! in, System.Action! pair) -> System.Threading.Tasks.Task! +static StackExchange.Redis.Configuration.LoggingTunnel.ValidateAsync(string! path) -> System.Threading.Tasks.Task! +static StackExchange.Redis.Configuration.LoggingTunnel.ValidateAsync(System.IO.Stream! stream) -> System.Threading.Tasks.Task! +[SER007]abstract StackExchange.Redis.Availability.CircuitBreaker.Accumulator.IsHealthy() -> bool +[SER007]abstract StackExchange.Redis.Availability.CircuitBreaker.Accumulator.ObserveResult(in StackExchange.Redis.Availability.CircuitBreaker.CircuitBreakerContext context) -> bool +[SER007]abstract StackExchange.Redis.Availability.CircuitBreaker.Accumulator.Reset() -> void +[SER007]abstract StackExchange.Redis.Availability.CircuitBreaker.CreateAccumulator() -> StackExchange.Redis.Availability.CircuitBreaker.Accumulator! +[SER007]StackExchange.Redis.Availability.CircuitBreaker +[SER007]StackExchange.Redis.Availability.CircuitBreaker.Accumulator +[SER007]StackExchange.Redis.Availability.CircuitBreaker.Accumulator.Accumulator() -> void +[SER007]StackExchange.Redis.Availability.CircuitBreaker.Builder +[SER007]StackExchange.Redis.Availability.CircuitBreaker.Builder.Builder() -> void +[SER007]StackExchange.Redis.Availability.CircuitBreaker.Builder.Create() -> StackExchange.Redis.Availability.CircuitBreaker! +[SER007]StackExchange.Redis.Availability.CircuitBreaker.Builder.FailureRateThreshold.get -> double +[SER007]StackExchange.Redis.Availability.CircuitBreaker.Builder.FailureRateThreshold.set -> void +[SER007]StackExchange.Redis.Availability.CircuitBreaker.Builder.MetricsWindowSize.get -> System.TimeSpan +[SER007]StackExchange.Redis.Availability.CircuitBreaker.Builder.MetricsWindowSize.set -> void +[SER007]StackExchange.Redis.Availability.CircuitBreaker.Builder.MinimumNumberOfFailures.get -> int +[SER007]StackExchange.Redis.Availability.CircuitBreaker.Builder.MinimumNumberOfFailures.set -> void +[SER007]StackExchange.Redis.Availability.CircuitBreaker.Builder.TrackedExceptions.get -> System.Type![]? +[SER007]StackExchange.Redis.Availability.CircuitBreaker.Builder.TrackedExceptions.set -> void +[SER007]StackExchange.Redis.Availability.CircuitBreaker.CircuitBreaker() -> void +[SER007]StackExchange.Redis.Availability.CircuitBreaker.CircuitBreakerContext +[SER007]StackExchange.Redis.Availability.CircuitBreaker.CircuitBreakerContext.CircuitBreakerContext() -> void +[SER007]StackExchange.Redis.Availability.CircuitBreaker.CircuitBreakerContext.CircuitBreakerContext(System.Exception? fault, bool evaluate = true) -> void +[SER007]StackExchange.Redis.Availability.CircuitBreaker.CircuitBreakerContext.Fault.get -> System.Exception? +[SER007]StackExchange.Redis.Availability.CircuitBreaker.CircuitBreakerContext.Success.get -> bool +[SER007]static StackExchange.Redis.Availability.CircuitBreaker.Builder.implicit operator StackExchange.Redis.Availability.CircuitBreaker!(StackExchange.Redis.Availability.CircuitBreaker.Builder! builder) -> StackExchange.Redis.Availability.CircuitBreaker! +[SER007]StackExchange.Redis.Availability.ConnectionGroupMember +[SER007]StackExchange.Redis.Availability.ConnectionGroupMember.ConnectionGroupMember(StackExchange.Redis.ConfigurationOptions! configuration, string! name = "") -> void +[SER007]StackExchange.Redis.Availability.ConnectionGroupMember.ConnectionGroupMember(string! configuration, string! name = "") -> void +[SER007]StackExchange.Redis.Availability.ConnectionGroupMember.IsConnected.get -> bool +[SER007]StackExchange.Redis.Availability.ConnectionGroupMember.Latency.get -> System.TimeSpan +[SER007]StackExchange.Redis.Availability.ConnectionGroupMember.Name.get -> string! +[SER007]StackExchange.Redis.Availability.ConnectionGroupMember.Weight.get -> double +[SER007]StackExchange.Redis.Availability.ConnectionGroupMember.Weight.set -> void +[SER007]StackExchange.Redis.Availability.GroupConnectionChangedEventArgs +[SER007]StackExchange.Redis.Availability.GroupConnectionChangedEventArgs.ChangeType +[SER007]StackExchange.Redis.Availability.GroupConnectionChangedEventArgs.ChangeType.ActiveChanged = 5 -> StackExchange.Redis.Availability.GroupConnectionChangedEventArgs.ChangeType +[SER007]StackExchange.Redis.Availability.GroupConnectionChangedEventArgs.ChangeType.Added = 1 -> StackExchange.Redis.Availability.GroupConnectionChangedEventArgs.ChangeType +[SER007]StackExchange.Redis.Availability.GroupConnectionChangedEventArgs.ChangeType.Disconnected = 3 -> StackExchange.Redis.Availability.GroupConnectionChangedEventArgs.ChangeType +[SER007]StackExchange.Redis.Availability.GroupConnectionChangedEventArgs.ChangeType.Reconnected = 4 -> StackExchange.Redis.Availability.GroupConnectionChangedEventArgs.ChangeType +[SER007]StackExchange.Redis.Availability.GroupConnectionChangedEventArgs.ChangeType.Removed = 2 -> StackExchange.Redis.Availability.GroupConnectionChangedEventArgs.ChangeType +[SER007]StackExchange.Redis.Availability.GroupConnectionChangedEventArgs.ChangeType.Unknown = 0 -> StackExchange.Redis.Availability.GroupConnectionChangedEventArgs.ChangeType +[SER007]StackExchange.Redis.Availability.GroupConnectionChangedEventArgs.Group.get -> StackExchange.Redis.Availability.ConnectionGroupMember! +[SER007]StackExchange.Redis.Availability.GroupConnectionChangedEventArgs.GroupConnectionChangedEventArgs(StackExchange.Redis.Availability.GroupConnectionChangedEventArgs.ChangeType type, StackExchange.Redis.Availability.ConnectionGroupMember! group, StackExchange.Redis.Availability.ConnectionGroupMember? previousGroup = null) -> void +[SER007]StackExchange.Redis.Availability.GroupConnectionChangedEventArgs.PreviousGroup.get -> StackExchange.Redis.Availability.ConnectionGroupMember? +[SER007]StackExchange.Redis.Availability.GroupConnectionChangedEventArgs.Type.get -> StackExchange.Redis.Availability.GroupConnectionChangedEventArgs.ChangeType +[SER007]StackExchange.Redis.Availability.HealthCheck +[SER007]StackExchange.Redis.Availability.HealthCheck.CheckHealthAsync(StackExchange.Redis.IConnectionMultiplexer! multiplexer) -> System.Threading.Tasks.Task! +[SER007]StackExchange.Redis.Availability.HealthCheck.CheckHealthAsync(StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! +[SER007]StackExchange.Redis.Availability.HealthCheck.Clone() -> StackExchange.Redis.Availability.HealthCheck! +[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheck() -> void +[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe +[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.HealthCheckProbe() -> void +[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext +[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext.Failure.get -> int +[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext.HealthCheckProbeContext() -> void +[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext.HealthCheckProbeContext(StackExchange.Redis.Availability.HealthCheck.HealthCheckResult result, int success, int failure, int remaining, System.TimeSpan probeInterval) -> void +[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext.ProbeInterval.get -> System.TimeSpan +[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext.Remaining.get -> int +[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext.Result.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckResult +[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext.Success.get -> int +[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy +[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy.HealthCheckProbePolicy() -> void +[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckResult +[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckResult.Healthy = 1 -> StackExchange.Redis.Availability.HealthCheck.HealthCheckResult +[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckResult.Inconclusive = 0 -> StackExchange.Redis.Availability.HealthCheck.HealthCheckResult +[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckResult.Unhealthy = 2 -> StackExchange.Redis.Availability.HealthCheck.HealthCheckResult +[SER007]StackExchange.Redis.Availability.HealthCheck.Interval.get -> System.TimeSpan +[SER007]StackExchange.Redis.Availability.HealthCheck.Interval.set -> void +[SER007]StackExchange.Redis.Availability.HealthCheck.KeyWriteHealthCheckProbe +[SER007]StackExchange.Redis.Availability.HealthCheck.KeyWriteHealthCheckProbe.KeyWriteHealthCheckProbe() -> void +[SER007]StackExchange.Redis.Availability.HealthCheck.Probe.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe! +[SER007]StackExchange.Redis.Availability.HealthCheck.Probe.set -> void +[SER007]StackExchange.Redis.Availability.HealthCheck.ProbeCount.get -> int +[SER007]StackExchange.Redis.Availability.HealthCheck.ProbeCount.set -> void +[SER007]StackExchange.Redis.Availability.HealthCheck.ProbeInterval.get -> System.TimeSpan +[SER007]StackExchange.Redis.Availability.HealthCheck.ProbeInterval.set -> void +[SER007]StackExchange.Redis.Availability.HealthCheck.ProbePolicy.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy! +[SER007]StackExchange.Redis.Availability.HealthCheck.ProbePolicy.set -> void +[SER007]StackExchange.Redis.Availability.HealthCheck.ProbeTimeout.get -> System.TimeSpan +[SER007]StackExchange.Redis.Availability.HealthCheck.ProbeTimeout.set -> void +[SER007]StackExchange.Redis.Availability.IConnectionGroup +[SER007]StackExchange.Redis.Availability.IConnectionGroup.ActiveMember.get -> StackExchange.Redis.Availability.ConnectionGroupMember? +[SER007]StackExchange.Redis.Availability.IConnectionGroup.AddAsync(StackExchange.Redis.Availability.ConnectionGroupMember! member, System.IO.TextWriter? log = null) -> System.Threading.Tasks.Task! +[SER007]StackExchange.Redis.Availability.IConnectionGroup.ConnectionChanged -> System.EventHandler? +[SER007]StackExchange.Redis.Availability.IConnectionGroup.GetMembers() -> System.ReadOnlySpan +[SER007]StackExchange.Redis.Availability.IConnectionGroup.Remove(StackExchange.Redis.Availability.ConnectionGroupMember! member) -> bool +[SER007]StackExchange.Redis.Availability.IConnectionGroup.TryFailoverTo(StackExchange.Redis.Availability.ConnectionGroupMember? member) -> bool +[SER007]StackExchange.Redis.Availability.MultiGroupOptions +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.CircuitBreaker.get -> StackExchange.Redis.Availability.CircuitBreaker! +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.CircuitBreaker.set -> void +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.HealthCheck.get -> StackExchange.Redis.Availability.HealthCheck! +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.HealthCheck.set -> void +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.MultiGroupOptions() -> void +[SER007]static StackExchange.Redis.ConnectionMultiplexer.ConnectGroupAsync(StackExchange.Redis.Availability.ConnectionGroupMember! member0, StackExchange.Redis.Availability.ConnectionGroupMember! member1, StackExchange.Redis.Availability.MultiGroupOptions? options = null, System.IO.TextWriter? log = null) -> System.Threading.Tasks.Task! +[SER007]static StackExchange.Redis.ConnectionMultiplexer.ConnectGroupAsync(StackExchange.Redis.Availability.ConnectionGroupMember![]! members, StackExchange.Redis.Availability.MultiGroupOptions? options = null, System.IO.TextWriter? log = null) -> System.Threading.Tasks.Task! +[SER007]static StackExchange.Redis.Availability.CircuitBreaker.Default.get -> StackExchange.Redis.Availability.CircuitBreaker! +[SER007]static StackExchange.Redis.Availability.CircuitBreaker.None.get -> StackExchange.Redis.Availability.CircuitBreaker! +[SER007]abstract StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.CheckHealthAsync(StackExchange.Redis.Availability.HealthCheck! healthCheck, StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! +[SER007]abstract StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy.Evaluate(in StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext context) -> StackExchange.Redis.Availability.HealthCheck.HealthCheckResult +[SER007]abstract StackExchange.Redis.Availability.HealthCheck.KeyWriteHealthCheckProbe.CheckHealthAsync(StackExchange.Redis.Availability.HealthCheck! healthCheck, StackExchange.Redis.IDatabaseAsync! database, StackExchange.Redis.RedisKey key) -> System.Threading.Tasks.Task! +[SER007]override StackExchange.Redis.Availability.ConnectionGroupMember.ToString() -> string! +[SER007]override StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext.ToString() -> string! +[SER007]override StackExchange.Redis.Availability.HealthCheck.KeyWriteHealthCheckProbe.CheckHealthAsync(StackExchange.Redis.Availability.HealthCheck! healthCheck, StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! +[SER007]static StackExchange.Redis.Availability.HealthCheck.Default.get -> StackExchange.Redis.Availability.HealthCheck! +[SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.HealthyTask.get -> System.Threading.Tasks.Task! +[SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.InconclusiveTask.get -> System.Threading.Tasks.Task! +[SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.IsConnected.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe! +[SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.Ping.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe! +[SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.StringSet.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe! +[SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.UnhealthyTask.get -> System.Threading.Tasks.Task! +[SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy.AllSuccess.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy! +[SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy.AnySuccess.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy! +[SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy.MajoritySuccess.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy! +[SER007]static StackExchange.Redis.Availability.MultiGroupOptions.Default.get -> StackExchange.Redis.Availability.MultiGroupOptions! +[SER007]StackExchange.Redis.ConnectionFailureType.CircuitBreaker = 11 -> StackExchange.Redis.ConnectionFailureType \ No newline at end of file diff --git a/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Unshipped.txt new file mode 100644 index 0000000000..85928bcdaa --- /dev/null +++ b/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Unshipped.txt @@ -0,0 +1,6 @@ +#nullable enable +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.DisposeAsync() -> System.Threading.Tasks.ValueTask +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Read(System.Span buffer) -> int +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.ReadAsync(System.Memory buffer, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Write(System.ReadOnlySpan buffer) -> void +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.WriteAsync(System.ReadOnlyMemory buffer, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask \ No newline at end of file diff --git a/src/StackExchange.Redis/RedisBatch.cs b/src/StackExchange.Redis/RedisBatch.cs index beda224af4..c827ba1dce 100644 --- a/src/StackExchange.Redis/RedisBatch.cs +++ b/src/StackExchange.Redis/RedisBatch.cs @@ -116,13 +116,13 @@ internal override Task ExecuteAsync(Message? message, ResultProcessor? internal override T ExecuteSync(Message? message, ResultProcessor? processor, ServerEndPoint? server = null, T? defaultValue = default) where T : default => throw new NotSupportedException("ExecuteSync cannot be used inside a batch"); - private static void FailNoServer(ConnectionMultiplexer muxer, List messages) + private static void FailNoServer(ConnectionMultiplexer muxer, List? messages) { - if (messages == null) return; + if (messages is null) return; foreach (var msg in messages) { msg.Fail(ConnectionFailureType.UnableToResolvePhysicalConnection, null, "unable to write batch", muxer); - msg.Complete(); + msg.Complete(null); } } } diff --git a/src/StackExchange.Redis/RedisTransaction.cs b/src/StackExchange.Redis/RedisTransaction.cs index 0c369e9883..5d54306710 100644 --- a/src/StackExchange.Redis/RedisTransaction.cs +++ b/src/StackExchange.Redis/RedisTransaction.cs @@ -237,17 +237,18 @@ public TransactionMessage(int db, CommandFlags flags, List? con this.conditions = (conditions?.Count > 0) ? conditions.ToArray() : Array.Empty(); } - internal override void SetExceptionAndComplete(Exception exception, PhysicalBridge? bridge) + internal override void SetExceptionAndComplete(Exception exception, PhysicalConnection? connection) { var inner = InnerOperations; - if (inner != null) + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (inner is not null) { for (int i = 0; i < inner.Length; i++) { - inner[i]?.Wrapped?.SetExceptionAndComplete(exception, bridge); + inner[i]?.Wrapped?.SetExceptionAndComplete(exception, connection); } } - base.SetExceptionAndComplete(exception, bridge); + base.SetExceptionAndComplete(exception, connection); } public bool IsAborted => command != RedisCommand.EXEC; @@ -432,7 +433,7 @@ public IEnumerable GetMessages(PhysicalConnection connection) { var inner = op.Wrapped; inner.Cancel(); - inner.Complete(); + inner.Complete(connection); } } connection.Trace("End of transaction: " + Command); @@ -484,7 +485,7 @@ public override bool SetResult(PhysicalConnection connection, Message message, r { var inner = op.Wrapped; ServerFail(inner, error); - inner.Complete(); + inner.Complete(connection); } } return base.SetResult(connection, message, ref copy); @@ -508,7 +509,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes { var inner = op.Wrapped; inner.Cancel(); - inner.Complete(); + inner.Complete(connection); } } SetResult(message, false); @@ -539,7 +540,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes muxer?.OnTransactionLog($"> got {iter.Value.GetOverview()} for {inner.CommandAndKey}"); if (inner.ComputeResult(connection, ref iter.Value)) { - inner.Complete(); + inner.Complete(connection); } } @@ -553,10 +554,10 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes // the pending tasks foreach (var op in wrapped) { - if (op?.Wrapped is Message inner) + if (op?.Wrapped is { } inner) { inner.Fail(ConnectionFailureType.ProtocolFailure, null, "Transaction failure", muxer); - inner.Complete(); + inner.Complete(connection); } } } diff --git a/src/StackExchange.Redis/ResultBox.cs b/src/StackExchange.Redis/ResultBox.cs index 20b76ba159..160493cc1c 100644 --- a/src/StackExchange.Redis/ResultBox.cs +++ b/src/StackExchange.Redis/ResultBox.cs @@ -6,6 +6,7 @@ namespace StackExchange.Redis { internal interface IResultBox { + Exception? Fault { get; } bool IsAsync { get; } bool IsFaulted { get; } void SetException(Exception ex); @@ -24,6 +25,7 @@ internal abstract class SimpleResultBox : IResultBox bool IResultBox.IsAsync => false; bool IResultBox.IsFaulted => _exception != null; + Exception? IResultBox.Fault => _exception; void IResultBox.SetException(Exception exception) => _exception = exception ?? CancelledException; void IResultBox.Cancel() => _exception = CancelledException; @@ -95,6 +97,7 @@ private TaskResultBox(object? asyncState, TaskCreationOptions creationOptions) : bool IResultBox.IsAsync => true; bool IResultBox.IsFaulted => _exception != null; + Exception? IResultBox.Fault => _exception; void IResultBox.Cancel() => _exception = SimpleResultBox.CancelledException; diff --git a/src/StackExchange.Redis/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs index c6abbd7337..eefe22ed5c 100644 --- a/src/StackExchange.Redis/ServerEndPoint.cs +++ b/src/StackExchange.Redis/ServerEndPoint.cs @@ -10,6 +10,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using StackExchange.Redis.Availability; using static StackExchange.Redis.PhysicalBridge; namespace StackExchange.Redis diff --git a/src/StackExchange.Redis/StackExchange.Redis.csproj b/src/StackExchange.Redis/StackExchange.Redis.csproj index addda59b7f..5681b41dc6 100644 --- a/src/StackExchange.Redis/StackExchange.Redis.csproj +++ b/src/StackExchange.Redis/StackExchange.Redis.csproj @@ -60,11 +60,14 @@ - + MultiGroupDatabase.cs - + HealthCheck.cs + + MultiGroupSubscriber.cs + \ No newline at end of file diff --git a/tests/StackExchange.Redis.Tests/ActiveActiveIntegrationTests.cs b/tests/StackExchange.Redis.Tests/ActiveActiveIntegrationTests.cs index fedf41bd33..406999a275 100644 --- a/tests/StackExchange.Redis.Tests/ActiveActiveIntegrationTests.cs +++ b/tests/StackExchange.Redis.Tests/ActiveActiveIntegrationTests.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using StackExchange.Redis.Availability; using StackExchange.Redis.Tests.Helpers; using Xunit; diff --git a/tests/StackExchange.Redis.Tests/CircuitBreakerServerTests.cs b/tests/StackExchange.Redis.Tests/CircuitBreakerServerTests.cs new file mode 100644 index 0000000000..074dafcd9a --- /dev/null +++ b/tests/StackExchange.Redis.Tests/CircuitBreakerServerTests.cs @@ -0,0 +1,83 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using StackExchange.Redis.Availability; +using Xunit; + +namespace StackExchange.Redis.Tests; + +public class CircuitBreakerServerTests(ITestOutputHelper output) : TestBase(output) +{ + [Fact] + public async Task CircuitBreakerObservesMessageResults() + { + using var server = new InProcessTestServer(); + + // take the template options from the server, and slot in our test breaker *before* connecting, + // so every physical connection yanks an accumulator from it during init + var config = server.GetClientConfig(); + var breaker = new CountingCircuitBreaker(); + config.CircuitBreaker = breaker; + + using var client = await ConnectionMultiplexer.ConnectAsync(config); + var db = client.GetDatabase(); + + // some successful operations (these, plus handshake traffic, count as non-fault observations) + RedisKey key = Me(); + await db.StringSetAsync(key, "abc"); + Assert.Equal("abc", await db.StringGetAsync(key)); + await db.StringGetAsync(key); + + var successesAfterGetSets = breaker.Successes; + + // and something the server doesn't support -> server replies with an error -> observed as a fault + var fault = await Assert.ThrowsAnyAsync(() => db.ExecuteAsync("nonesuch")); + Output.WriteLine($"execute fault: {fault.GetType().Name}: {fault.Message}"); + + Output.WriteLine($"observed successes={breaker.Successes}, failures={breaker.Failures}, lastFault={breaker.LastFault?.GetType().Name}"); + + // the get/sets were observed as successes + Assert.True(successesAfterGetSets > 0, "expected the successful operations to be observed"); + + // exactly one fault (the unsupported command), captured as the clean server error. A healthy + // breaker must NOT tear the connection down: a regression there shows up here as a + // RedisConnectionException (and extra faults) rather than this RedisServerException. + Assert.Equal(1, breaker.Failures); + Assert.IsType(breaker.LastFault); + } + + // a minimal breaker for tests: shares counters across all accumulators it creates, so we can + // observe traffic across every physical connection; it never trips (always reports healthy) + private sealed class CountingCircuitBreaker : CircuitBreaker + { + private int _successes, _failures; + + public int Successes => Volatile.Read(ref _successes); + public int Failures => Volatile.Read(ref _failures); + public Exception? LastFault { get; private set; } + + public override Accumulator CreateAccumulator() => new CountingAccumulator(this); + + private sealed class CountingAccumulator(CountingCircuitBreaker owner) : Accumulator + { + public override bool ObserveResult(in CircuitBreakerContext context) + { + if (context.Success) + { + Interlocked.Increment(ref owner._successes); + } + else + { + Interlocked.Increment(ref owner._failures); + owner.LastFault = context.Fault; + } + + return true; // stay healthy; we're only here to observe, not to trip the connection + } + + public override bool IsHealthy() => true; + + public override void Reset() { } + } + } +} diff --git a/tests/StackExchange.Redis.Tests/CircuitBreakerUnitTests.cs b/tests/StackExchange.Redis.Tests/CircuitBreakerUnitTests.cs new file mode 100644 index 0000000000..a7c25a4888 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/CircuitBreakerUnitTests.cs @@ -0,0 +1,239 @@ +using System; +using StackExchange.Redis.Availability; +using Xunit; +#if NET8_0_OR_GREATER +using System.Threading; +#endif + +namespace StackExchange.Redis.Tests; + +public class CircuitBreakerUnitTests +{ + [Fact] + public void Builder_AllDefaults_ReturnsSharedDefaultInstance() + { + // a builder that hasn't been touched should collapse onto the shared default instance... + var a = new CircuitBreaker.Builder().Create(); + var b = new CircuitBreaker.Builder().Create(); + + Assert.Same(a, b); + Assert.Same(CircuitBreaker.Default, a); + } + + [Fact] + public void Builder_NonDefaults_ReturnsDistinctValidInstances() + { + // ...but as soon as any knob is changed, we get a fresh, distinct instance per Create() + CircuitBreaker.Builder Configured() => new() { FailureRateThreshold = 42 }; + + var a = Configured().Create(); + var b = Configured().Create(); + + Assert.NotNull(a); + Assert.NotNull(b); + Assert.NotSame(a, b); + Assert.NotSame(CircuitBreaker.Default, a); + } + + [Fact] + public void None_IsDistinctFromDefault_ButStable() + { + Assert.NotNull(CircuitBreaker.None); + Assert.Same(CircuitBreaker.None, CircuitBreaker.None); + Assert.NotSame(CircuitBreaker.Default, CircuitBreaker.None); + } + + [Fact] + public void None_IsAlwaysHealthy() + { + var acc = CircuitBreaker.None.CreateAccumulator(); + // even a solid wall of tracked failures never trips the no-op breaker + Assert.True(Record(acc, 10_000, new RedisTimeoutException("boom", CommandStatus.Unknown))); + } + + [Fact] + public void NonEvaluatingObservation_IsNeverTreatedAsUnhealthy() + { + // a deliberately naive breaker whose accumulator *always* reports unhealthy - e.g. one that + // returns default(false) whether or not it was actually asked to evaluate. A success is + // observed without evaluation, so that bogus verdict must be ignored (not read as a trip); + // only a genuine evaluation (a fault) is allowed to report unhealthy. + var acc = new AlwaysUnhealthyBreaker().CreateAccumulator(); + + Assert.True(acc.ObserveResult((Exception?)null)); // success -> not evaluating -> verdict ignored + Assert.False(acc.ObserveResult(Timeout())); // fault -> evaluating -> verdict honoured + } + + // never reports healthy, even when not evaluating; stands in for a buggy/naive custom breaker + private sealed class AlwaysUnhealthyBreaker : CircuitBreaker + { + public override Accumulator CreateAccumulator() => new Acc(); + + private sealed class Acc : Accumulator + { + public override bool ObserveResult(in CircuitBreakerContext context) => false; + public override bool IsHealthy() => false; + public override void Reset() { } + } + } + +#if NET8_0_OR_GREATER + // the time-windowed logic needs a controllable clock; TimeProvider is only available on net8.0+, + // and we don't want to pull the BCL shim in just for down-level test coverage. + [Fact] + public void BelowMinimumFailures_StaysHealthy() + { + var time = new ManualTimeProvider(); + // threshold is trivially low (1%), so the *only* thing keeping us healthy is the minimum-count gate + var acc = Build(time, failureRateThreshold: 1, minimumNumberOfFailures: 10).CreateAccumulator(); + + // nine tracked failures: one short of the minimum, so we withhold judgement + Assert.True(Record(acc, 9, Timeout())); + } + + [Fact] + public void AboveThreshold_Trips() + { + var time = new ManualTimeProvider(); + var acc = Build(time, failureRateThreshold: 50, minimumNumberOfFailures: 10).CreateAccumulator(); + + // 20 tracked failures, 0 successes -> 100% failure rate, well past both gates + Assert.False(Record(acc, 20, Timeout())); + } + + [Fact] + public void BelowThreshold_StaysHealthy() + { + var time = new ManualTimeProvider(); + var acc = Build(time, failureRateThreshold: 50, minimumNumberOfFailures: 10).CreateAccumulator(); + + Record(acc, 10, Timeout()); // enough failures to clear the minimum-count gate + Record(acc, 190); // but drowned out by successes -> 5% failure rate + + // a pure health read confirms we're comfortably under the 50% threshold + Assert.True(acc.IsHealthy()); + } + + [Fact] + public void UntrackedExceptions_CountAsSuccess() + { + var time = new ManualTimeProvider(); + // default tracking set (null) == RedisConnectionException + RedisTimeoutException only + var acc = Build(time, failureRateThreshold: 1, minimumNumberOfFailures: 1).CreateAccumulator(); + + // a flood of *untracked* failures must not trip the breaker... + Assert.True(Record(acc, 100, new InvalidOperationException("not tracked"))); + + // ...whereas the same volume of tracked failures does + Assert.False(Record(acc, 100, Timeout())); + } + + [Fact] + public void CustomTrackedExceptions_AreHonoured() + { + var time = new ManualTimeProvider(); + var acc = Build( + time, + failureRateThreshold: 1, + minimumNumberOfFailures: 1, + trackedExceptions: [typeof(InvalidOperationException)]).CreateAccumulator(); + + // the default Redis faults are now *un*tracked, so they read as success + Assert.True(Record(acc, 100, Timeout())); + + // whereas our nominated type trips it + Assert.False(Record(acc, 100, new InvalidOperationException("tracked"))); + } + + [Fact] + public void OldFailures_AgeOutOfTheWindow() + { + var time = new ManualTimeProvider(); + var acc = Build(time, failureRateThreshold: 50, minimumNumberOfFailures: 1).CreateAccumulator(); + + // saturate the window with failures -> tripped + Assert.False(Record(acc, 100, Timeout())); + + // step past the whole window; the earlier failures should no longer count + time.Advance(TimeSpan.FromSeconds(11)); + + // the window is now empty of in-range failures -> healthy again + Assert.True(acc.IsHealthy()); + } + + [Fact] + public void IsHealthy_ReflectsStateWithoutObserving() + { + var time = new ManualTimeProvider(); + var acc = Build(time, failureRateThreshold: 50, minimumNumberOfFailures: 1).CreateAccumulator(); + + Assert.True(acc.IsHealthy()); // nothing observed yet + + Assert.False(Record(acc, 100, Timeout())); // trip it via observations + + // the context-free overload reports the same verdict, purely by reading the window + Assert.False(acc.IsHealthy()); + } + + [Fact] + public void Reset_DiscardsHistory() + { + var time = new ManualTimeProvider(); + var acc = Build(time, failureRateThreshold: 50, minimumNumberOfFailures: 1).CreateAccumulator(); + + // trip it wide open... + Assert.False(Record(acc, 100, Timeout())); + + // ...then wipe the slate; the prior failures are forgotten + acc.Reset(); + + // an empty window reads as healthy again + Assert.True(acc.IsHealthy()); + } + + private static CircuitBreaker Build( + TimeProvider time, + double failureRateThreshold, + int minimumNumberOfFailures, + Type[]? trackedExceptions = null) + => new CircuitBreaker.Builder + { + FailureRateThreshold = failureRateThreshold, + MinimumNumberOfFailures = minimumNumberOfFailures, + MetricsWindowSize = TimeSpan.FromSeconds(10), + TrackedExceptions = trackedExceptions, + TimeProvider = time, + }.Create(); + + /// + /// A hand-cranked whose clock only moves when we tell it to, + /// so the bucketed metrics window is fully deterministic. + /// + private sealed class ManualTimeProvider : TimeProvider + { + private long _timestamp; + + // one tick == 100ns, matching TimeSpan; keeps Advance(TimeSpan) a straight addition + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override long GetTimestamp() => Interlocked.Read(ref _timestamp); + + public void Advance(TimeSpan by) => Interlocked.Add(ref _timestamp, by.Ticks); + } +#endif + + private static RedisTimeoutException Timeout() => new("timeout", CommandStatus.Unknown); + + private static bool Record(CircuitBreaker.Accumulator accumulator, int count, Exception? fault = null) + { + // success/failure is derived from the presence of a fault; pass a fault for a failure, none for a success + var context = new CircuitBreaker.CircuitBreakerContext(fault); + bool healthy = true; + for (int i = 0; i < count; i++) + { + healthy = accumulator.ObserveResult(in context); + } + + return healthy; + } +} diff --git a/tests/StackExchange.Redis.Tests/ConfigTests.cs b/tests/StackExchange.Redis.Tests/ConfigTests.cs index 89ffd851e0..93f0fbc1e6 100644 --- a/tests/StackExchange.Redis.Tests/ConfigTests.cs +++ b/tests/StackExchange.Redis.Tests/ConfigTests.cs @@ -69,6 +69,7 @@ orderby name "CertificateSelection", "CertificateValidation", "ChannelPrefix", + "CircuitBreaker", "ClientName", "commandMap", "configChannel", diff --git a/tests/StackExchange.Redis.Tests/HealthCheckPolicyUnitTests.cs b/tests/StackExchange.Redis.Tests/HealthCheckPolicyUnitTests.cs index b2dd97b711..1bbcebb4e0 100644 --- a/tests/StackExchange.Redis.Tests/HealthCheckPolicyUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/HealthCheckPolicyUnitTests.cs @@ -1,6 +1,6 @@ using System; using Xunit; -using static StackExchange.Redis.HealthCheck; +using static StackExchange.Redis.Availability.HealthCheck; namespace StackExchange.Redis.Tests; diff --git a/tests/StackExchange.Redis.Tests/MultiGroupTests/BasicMultiGroupTests.cs b/tests/StackExchange.Redis.Tests/MultiGroupTests/BasicMultiGroupTests.cs index 7072508599..a99fcd724c 100644 --- a/tests/StackExchange.Redis.Tests/MultiGroupTests/BasicMultiGroupTests.cs +++ b/tests/StackExchange.Redis.Tests/MultiGroupTests/BasicMultiGroupTests.cs @@ -3,6 +3,7 @@ using System.IO; using System.Net; using System.Threading.Tasks; +using StackExchange.Redis.Availability; using StackExchange.Redis.Tests.Helpers; using Xunit; diff --git a/tests/StackExchange.Redis.Tests/MultiGroupTests/CircuitBreakerRerouteTests.cs b/tests/StackExchange.Redis.Tests/MultiGroupTests/CircuitBreakerRerouteTests.cs new file mode 100644 index 0000000000..18436db467 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/MultiGroupTests/CircuitBreakerRerouteTests.cs @@ -0,0 +1,132 @@ +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using StackExchange.Redis.Availability; +using Xunit; + +namespace StackExchange.Redis.Tests.MultiGroupTests; + +[RunPerProtocol] +public class CircuitBreakerRerouteTests(ITestOutputHelper log) +{ + // A circuit-breaker trip must steer the group away from the affected member *promptly* - i.e. via + // the shim's ConnectionFailed(CircuitBreaker) fast-path, not by waiting for the next health-check + // poll. To isolate that mechanism we make the poll interval enormous, so the *only* thing that can + // reroute inside the test window is the fast-path; and we hold the tripped member unhealthy via a + // controllable probe, so the reroute is deterministic even though the physical connection reconnects + // immediately after being torn down. + [Fact] + public async Task CircuitBreakerTrip_ReroutesAwayFromMember() + { + EndPoint alpha = new DnsEndPoint("alpha", 6379); + EndPoint bravo = new DnsEndPoint("bravo", 6379); + EndPoint charlie = new DnsEndPoint("charlie", 6379); + + using var serverA = new InProcessTestServer(log, endpoint: alpha); + using var serverB = new InProcessTestServer(log, endpoint: bravo); + using var serverC = new InProcessTestServer(log, endpoint: charlie); + + var breaker = new FlipBreaker(); + var probe = new ControllableProbe(); + + // only member A carries the trippable breaker; B and C are left with the default (which requires + // an implausible number of failures to trip, so they never interfere) + var configA = serverA.GetClientConfig(); + configA.CircuitBreaker = breaker; + + ConnectionGroupMember[] members = + [ + new(configA, "A") { Weight = 9 }, // highest weight -> initially active + new(serverB.GetClientConfig(), "B") { Weight = 3 }, // preferred failover target + new(serverC.GetClientConfig(), "C") { Weight = 1 }, + ]; + + var options = new MultiGroupOptions + { + HealthCheck = new HealthCheck + { + Probe = probe, + // enormous, so the poll loop cannot be what reroutes us during the test + Interval = TimeSpan.FromMinutes(30), + ProbeCount = 1, + ProbeTimeout = TimeSpan.FromSeconds(5), + }, + }; + + await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members, options); + var typed = Assert.IsType(conn); + + int circuitBreakerEvents = 0; + typed.ConnectionFailed += (_, e) => + { + log.WriteLine($"ConnectionFailed: {e.FailureType} @ {e.EndPoint}"); + if (e.FailureType == ConnectionFailureType.CircuitBreaker) + { + Interlocked.Increment(ref circuitBreakerEvents); + } + }; + + // sanity: A (highest weight) is the active member to begin with + Assert.True(conn.IsConnected); + Assert.Same(members[0], conn.ActiveMember); + + // arm the breaker and hold A unhealthy, then drive a *faulting* command to the active member (A): + // the fault is what the breaker evaluates, and a tripped breaker tears the connection down + probe.MarkDown(alpha); + breaker.Trip(); + var db = conn.GetDatabase(); + var fault = await Assert.ThrowsAnyAsync(() => db.ExecuteAsync("nonesuch")); + log.WriteLine($"observed fault: {fault.GetType().Name}: {fault.Message}"); + + // wait (briefly) for the fast-path to react; nothing else can move us within this window + var moved = await WaitForActiveAsync(conn, notMember: members[0], timeout: TimeSpan.FromSeconds(5)); + + Assert.True(moved, "expected the circuit-breaker trip to reroute away from member A"); + Assert.Same(members[1], conn.ActiveMember); // B is the next-highest weight + Assert.True(circuitBreakerEvents > 0, "expected a ConnectionFailed event with FailureType == CircuitBreaker"); + } + + private static async Task WaitForActiveAsync( + IConnectionGroup conn, ConnectionGroupMember notMember, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (!ReferenceEquals(conn.ActiveMember, notMember) && conn.ActiveMember is not null) + { + return true; + } + await Task.Delay(50, TestContext.Current.CancellationToken); + } + return false; + } + + // trips on demand: healthy until Trip() is called; the trip only actuates on a *fault* observation + // (successes are never evaluated, by design), which the test provides via a bad command + private sealed class FlipBreaker : CircuitBreaker + { + private volatile bool _tripped; + public void Trip() => _tripped = true; + + public override Accumulator CreateAccumulator() => new Acc(this); + + private sealed class Acc(FlipBreaker owner) : Accumulator + { + public override bool ObserveResult(in CircuitBreakerContext context) => !owner._tripped; + public override bool IsHealthy() => !owner._tripped; + public override void Reset() { } + } + } + + // reports the nominated endpoints unhealthy on demand; everything else is healthy. This keeps the + // tripped member deselected even after its physical connection reconnects. + private sealed class ControllableProbe : HealthCheck.HealthCheckProbe + { + private volatile EndPoint? _down; + public void MarkDown(EndPoint endpoint) => _down = endpoint; + + public override Task CheckHealthAsync(HealthCheck healthCheck, IServer server) + => Equals(server.EndPoint, _down) ? UnhealthyTask : HealthyTask; + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultBoxTests.cs b/tests/StackExchange.Redis.Tests/ResultBoxTests.cs index adb1b309f2..99c8358e87 100644 --- a/tests/StackExchange.Redis.Tests/ResultBoxTests.cs +++ b/tests/StackExchange.Redis.Tests/ResultBoxTests.cs @@ -46,7 +46,7 @@ public void SyncResultBox() Assert.Equal(0, Volatile.Read(ref activated)); // check that complete signals continuation - msg.Complete(); + msg.Complete(null); Thread.Sleep(100); Assert.Equal(1, Volatile.Read(ref activated)); @@ -81,7 +81,7 @@ public void TaskResultBox() Assert.False(tcs.Task.IsCompleted); // check that complete signals continuation - msg.Complete(); + msg.Complete(null); Thread.Sleep(100); Assert.True(tcs.Task.IsCompleted); From 4eb4dbdbf4628513af1e500e70c8e89f66c96e3c Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 10 Jul 2026 11:48:26 +0100 Subject: [PATCH 09/33] - expose health-check and circuit-breaker on the per-member options - fix public API for updated lib --- Directory.Packages.props | 2 +- .../PublicAPI/Debug/PublicAPI.Shipped.txt | 2 + src/RESPite/PublicAPI/PublicAPI.Shipped.txt | 1 - .../PublicAPI/Release/PublicAPI.Shipped.txt | 2 + src/RESPite/RESPite.csproj | 2 + .../APITypes/StreamInfo.cs | 42 ++++++------------- .../Availability/MultiGroupMultiplexer.cs | 8 +++- .../ConfigurationOptions.cs | 18 ++++++-- src/StackExchange.Redis/KeyNotification.cs | 12 +++--- .../PublicAPI/PublicAPI.Shipped.txt | 2 +- .../PublicAPI/PublicAPI.Unshipped.txt | 4 ++ src/StackExchange.Redis/ValueCondition.cs | 7 +--- .../StackExchange.Redis.Tests/ConfigTests.cs | 1 + 13 files changed, 53 insertions(+), 50 deletions(-) create mode 100644 src/RESPite/PublicAPI/Debug/PublicAPI.Shipped.txt create mode 100644 src/RESPite/PublicAPI/Release/PublicAPI.Shipped.txt diff --git a/Directory.Packages.props b/Directory.Packages.props index 88bb94c5eb..c5261d0e17 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,7 +18,7 @@ - + diff --git a/src/RESPite/PublicAPI/Debug/PublicAPI.Shipped.txt b/src/RESPite/PublicAPI/Debug/PublicAPI.Shipped.txt new file mode 100644 index 0000000000..804e940b99 --- /dev/null +++ b/src/RESPite/PublicAPI/Debug/PublicAPI.Shipped.txt @@ -0,0 +1,2 @@ +#nullable enable +[SERDBG]RESPite.Messages.RespReader.AggregateEnumerator.Current.get -> RESPite.Messages.RespReader diff --git a/src/RESPite/PublicAPI/PublicAPI.Shipped.txt b/src/RESPite/PublicAPI/PublicAPI.Shipped.txt index 412c528620..eb6ae9cbdb 100644 --- a/src/RESPite/PublicAPI/PublicAPI.Shipped.txt +++ b/src/RESPite/PublicAPI/PublicAPI.Shipped.txt @@ -114,7 +114,6 @@ [SER004]RESPite.Messages.RespReader.AggregateEnumerator [SER004]RESPite.Messages.RespReader.AggregateEnumerator.AggregateEnumerator() -> void [SER004]RESPite.Messages.RespReader.AggregateEnumerator.AggregateEnumerator(scoped in RESPite.Messages.RespReader reader) -> void -[SER004]RESPite.Messages.RespReader.AggregateEnumerator.Current.get -> RESPite.Messages.RespReader [SER004]RESPite.Messages.RespReader.AggregateEnumerator.DemandNext() -> void [SER004]RESPite.Messages.RespReader.AggregateEnumerator.FillAll(scoped System.Span target, RESPite.Messages.RespReader.Projection! first, RESPite.Messages.RespReader.Projection! second, System.Func! combine) -> void [SER004]RESPite.Messages.RespReader.AggregateEnumerator.GetEnumerator() -> RESPite.Messages.RespReader.AggregateEnumerator diff --git a/src/RESPite/PublicAPI/Release/PublicAPI.Shipped.txt b/src/RESPite/PublicAPI/Release/PublicAPI.Shipped.txt new file mode 100644 index 0000000000..dd1130e4cb --- /dev/null +++ b/src/RESPite/PublicAPI/Release/PublicAPI.Shipped.txt @@ -0,0 +1,2 @@ +#nullable enable +[SER004]RESPite.Messages.RespReader.AggregateEnumerator.Current.get -> RESPite.Messages.RespReader diff --git a/src/RESPite/RESPite.csproj b/src/RESPite/RESPite.csproj index 6b9798b0e7..8acf9429c4 100644 --- a/src/RESPite/RESPite.csproj +++ b/src/RESPite/RESPite.csproj @@ -40,6 +40,8 @@ + + diff --git a/src/StackExchange.Redis/APITypes/StreamInfo.cs b/src/StackExchange.Redis/APITypes/StreamInfo.cs index 1de0526ecd..bef227d736 100644 --- a/src/StackExchange.Redis/APITypes/StreamInfo.cs +++ b/src/StackExchange.Redis/APITypes/StreamInfo.cs @@ -103,57 +103,39 @@ internal StreamInfo( /// /// The duration value configured for the stream’s IDMP map (seconds), or -1 if unavailable. /// - public long IdmpDuration - { - [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] - get; - } + [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] + public long IdmpDuration { get; } /// /// The maxsize value configured for the stream’s IDMP map, or -1 if unavailable. /// - public long IdmpMaxSize - { - [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] - get; - } + [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] + public long IdmpMaxSize { get; } /// /// The number of idempotent pids currently tracked in the stream, or -1 if unavailable. /// - public long PidsTracked - { - [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] - get; - } + [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] + public long PidsTracked { get; } /// /// The number of idempotent ids currently tracked in the stream, or -1 if unavailable. /// This count reflects active iids that haven't expired or been evicted yet. /// - public long IidsTracked - { - [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] - get; - } + [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] + public long IidsTracked { get; } /// /// The count of all entries with an idempotent iid added to the stream during its lifetime, or -1 if unavailable. /// This is a cumulative counter that increases with each idempotent entry added. /// - public long IidsAdded - { - [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] - get; - } + [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] + public long IidsAdded { get; } /// /// The count of all duplicate iids (for all pids) detected during the stream's lifetime, or -1 if unavailable. /// This is a cumulative counter that increases with each duplicate iid. /// - public long IidsDuplicates - { - [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] - get; - } + [Experimental(Experiments.Server_8_6, UrlFormat = Experiments.UrlFormat)] + public long IidsDuplicates { get; } } diff --git a/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs index b4e47fcce9..ce61a1f580 100644 --- a/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs @@ -434,7 +434,10 @@ internal async Task RunHealthCheckAsync() var pending = HealthCheck.GetReusablePending(ref _reusableHealthCheckBuffer, members.Length); for (int i = 0; i < members.Length; i++) { - pending[i] = healthCheck.CheckHealthAsync(members[i].Multiplexer); + // per-member health-check overrides the group default when specified (see the group/muxer + // split on circuit-breakers); left null, we fall back to the shared group health-check + var muxer = members[i].Multiplexer; + pending[i] = (muxer.RawConfig.HealthCheck ?? healthCheck).CheckHealthAsync(muxer); } await Task.WhenAll(pending).TimeoutAfter(healthCheck.TotalTimeoutMillis()).ForAwait(); @@ -1095,7 +1098,8 @@ public async Task AddAsync(ConnectionGroupMember member, TextWriter? log = null) member.Configuration.CircuitBreaker ??= _options.CircuitBreaker; // AA options flow into the children var muxer = await ConnectionMultiplexer.ConnectAsync(member.Configuration, log).ConfigureAwait(false); member.SetMultiplexer(muxer); - var health = await _options.HealthCheck.CheckHealthAsync(muxer).ConfigureAwait(false); + var health = await (muxer.RawConfig.HealthCheck ?? _options.HealthCheck) + .CheckHealthAsync(muxer).ConfigureAwait(false); member.UpdateState(health); // apply any shared hooks diff --git a/src/StackExchange.Redis/ConfigurationOptions.cs b/src/StackExchange.Redis/ConfigurationOptions.cs index 5a497e8252..ee6cd6f32d 100644 --- a/src/StackExchange.Redis/ConfigurationOptions.cs +++ b/src/StackExchange.Redis/ConfigurationOptions.cs @@ -980,6 +980,7 @@ public static ConfigurationOptions Parse(string configuration, bool ignoreUnknow heartbeatInterval = heartbeatInterval, WriteMode = WriteMode, CircuitBreaker = CircuitBreaker, + HealthCheck = HealthCheck, #if DEBUG OutputLog = OutputLog, #endif @@ -1184,6 +1185,7 @@ private void Clear() _protocol = default; WriteMode = default; CircuitBreaker = null; + HealthCheck = null; #if DEBUG OutputLog = null; #endif @@ -1383,9 +1385,19 @@ public RedisProtocol? Protocol internal BufferedStreamWriter.WriteMode WriteMode { get; set; } - // circuit-breaker to apply to physical connections; when null, no breaker is used. Not surfaced as - // public API for now (minimizing drift); flows in from MultiGroupOptions when connecting a group. - internal CircuitBreaker? CircuitBreaker { get; set; } + /// + /// The circuit-breaker to apply to physical connections; when null, no breaker is used. When + /// connecting a group, this flows in from if not set explicitly. + /// + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + public CircuitBreaker? CircuitBreaker { get; set; } + + /// + /// The health-check to apply when this connection is a member of a group; when null, the + /// group-level is used. + /// + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + public HealthCheck? HealthCheck { get; set; } internal bool AllowSimulateConnectionFailure { diff --git a/src/StackExchange.Redis/KeyNotification.cs b/src/StackExchange.Redis/KeyNotification.cs index e52b8ead57..d726d750c8 100644 --- a/src/StackExchange.Redis/KeyNotification.cs +++ b/src/StackExchange.Redis/KeyNotification.cs @@ -82,13 +82,11 @@ public readonly ref partial struct KeyNotification /// /// This is true for SubKeySpace, SubKeyEvent, SubKeySpaceItem, and SubKeySpaceEvent notifications (Redis 8.8+). [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] - public bool HasSubKey - { - get => _kind is KeyNotificationKind.SubKeySpace - or KeyNotificationKind.SubKeyEvent - or KeyNotificationKind.SubKeySpaceItem - or KeyNotificationKind.SubKeySpaceEvent; - } + public bool HasSubKey => + _kind is KeyNotificationKind.SubKeySpace + or KeyNotificationKind.SubKeyEvent + or KeyNotificationKind.SubKeySpaceItem + or KeyNotificationKind.SubKeySpaceEvent; /// /// If the channel is a keyspace, keyevent, subkeyspace, subkeyevent, subkeyspaceitem, or subkeyeventitem notification, resolve the key and event type. diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt index cc104c5777..7536a1d27f 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt @@ -2356,7 +2356,7 @@ StackExchange.Redis.ProductVariant.Valkey = 1 -> StackExchange.Redis.ProductVari [SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.MoveNext() -> bool [SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.TryCopyTo(scoped System.Span destination, out int bytesWritten) -> bool [SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.TryCopyTo(scoped System.Span destination, out int charsWritten) -> bool -StackExchange.Redis.KeyNotification.HasSubKey.get -> bool +[SER006]StackExchange.Redis.KeyNotification.HasSubKey.get -> bool StackExchange.Redis.KeyNotification.Kind.get -> StackExchange.Redis.KeyNotificationKind StackExchange.Redis.KeyNotificationKind StackExchange.Redis.KeyNotificationKind.KeyEvent = 2 -> StackExchange.Redis.KeyNotificationKind diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index b1d16d31f1..f7a389601d 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -154,4 +154,8 @@ static StackExchange.Redis.Configuration.LoggingTunnel.ValidateAsync(System.IO.S [SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy.AnySuccess.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy! [SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy.MajoritySuccess.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy! [SER007]static StackExchange.Redis.Availability.MultiGroupOptions.Default.get -> StackExchange.Redis.Availability.MultiGroupOptions! +[SER007]StackExchange.Redis.ConfigurationOptions.CircuitBreaker.get -> StackExchange.Redis.Availability.CircuitBreaker? +[SER007]StackExchange.Redis.ConfigurationOptions.CircuitBreaker.set -> void +[SER007]StackExchange.Redis.ConfigurationOptions.HealthCheck.get -> StackExchange.Redis.Availability.HealthCheck? +[SER007]StackExchange.Redis.ConfigurationOptions.HealthCheck.set -> void [SER007]StackExchange.Redis.ConnectionFailureType.CircuitBreaker = 11 -> StackExchange.Redis.ConnectionFailureType \ No newline at end of file diff --git a/src/StackExchange.Redis/ValueCondition.cs b/src/StackExchange.Redis/ValueCondition.cs index 7516e74c0e..4164956e53 100644 --- a/src/StackExchange.Redis/ValueCondition.cs +++ b/src/StackExchange.Redis/ValueCondition.cs @@ -109,11 +109,8 @@ public override string ToString() /// /// Gets the underlying value for this condition. /// - public RedisValue Value - { - [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] - get => _value; - } + [Experimental(Experiments.Server_8_4, UrlFormat = Experiments.UrlFormat)] + public RedisValue Value => _value; private ValueCondition(ConditionKind kind, in RedisValue value) { diff --git a/tests/StackExchange.Redis.Tests/ConfigTests.cs b/tests/StackExchange.Redis.Tests/ConfigTests.cs index 93f0fbc1e6..83c02bbcb3 100644 --- a/tests/StackExchange.Redis.Tests/ConfigTests.cs +++ b/tests/StackExchange.Redis.Tests/ConfigTests.cs @@ -80,6 +80,7 @@ orderby name "defaultOptions", "defaultVersion", "EndPoints", + "HealthCheck", "heartbeatInterval", "keepAlive", "LibraryName", From 8c7ae64987f762dd562dc5e310d205d78f5cb88e Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 10 Jul 2026 12:07:16 +0100 Subject: [PATCH 10/33] update health-check defaults --- docs/ActiveActive.md | 26 +++++++++---------- docs/Configuration.md | 14 ++++++++++ .../Availability/HealthCheck.cs | 8 +++--- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/docs/ActiveActive.md b/docs/ActiveActive.md index e49bbf88a2..0cc4f7de62 100644 --- a/docs/ActiveActive.md +++ b/docs/ActiveActive.md @@ -207,12 +207,12 @@ Health checks are configured globally for all members using the `MultiGroupOptio ```csharp var healthCheck = new HealthCheck { - Interval = TimeSpan.FromSeconds(10), // How often to check health - ProbeCount = 3, // Maximum number of probe attempts per check - ProbeTimeout = TimeSpan.FromSeconds(2), // Timeout for each probe attempt - ProbeInterval = TimeSpan.FromSeconds(1), // Delay between failed probes - Probe = HealthCheckProbe.Ping, // Which probe type to use - ProbePolicy = HealthCheckProbePolicy.AnySuccess // Evaluation policy + Interval = TimeSpan.FromSeconds(5), // How often to check health + ProbeCount = 3, // Maximum number of probe attempts per check + ProbeTimeout = TimeSpan.FromSeconds(3), // Timeout for each probe attempt + ProbeInterval = TimeSpan.FromMilliseconds(500), // Delay between failed probes + Probe = HealthCheckProbe.Ping, // Which probe type to use + ProbePolicy = HealthCheckProbePolicy.AllSuccess // Evaluation policy }; var options = new MultiGroupOptions @@ -248,7 +248,7 @@ You can also clone and customize the default: ```csharp var customHealthCheck = HealthCheck.Default.Clone(); -customHealthCheck.Interval = TimeSpan.FromSeconds(5); +customHealthCheck.Interval = TimeSpan.FromSeconds(15); customHealthCheck.ProbeCount = 5; var options = new MultiGroupOptions @@ -263,12 +263,12 @@ The `HealthCheck` class provides several configurable properties: | Property | Default | Description | |----------|---------|-------------| -| `Interval` | 10 seconds | How frequently health checks are performed | +| `Interval` | 5 seconds | How frequently health checks are performed | | `ProbeCount` | 3 | Number of probe operations to perform per health check | -| `ProbeTimeout` | 2 seconds | Maximum time allowed for an individual probe to complete | -| `ProbeInterval` | 1 second | Delay between consecutive failed probes | +| `ProbeTimeout` | 3 seconds | Maximum time allowed for an individual probe to complete | +| `ProbeInterval` | 500 milliseconds | Delay between consecutive failed probes | | `Probe` | `Ping` | The probe operation to execute | -| `ProbePolicy` | `AnySuccess` | Policy for evaluating multiple probe results | +| `ProbePolicy` | `AllSuccess` | Policy for evaluating multiple probe results | ### Built-in Probes @@ -317,7 +317,7 @@ This probe writes a random value to a health check key and verifies it can be re The probe policy determines how multiple probe results are evaluated to determine overall health: -#### HealthCheckProbePolicy.AnySuccess (Default) +#### HealthCheckProbePolicy.AnySuccess The health check passes if **any** probe succeeds. This provides the most lenient evaluation: @@ -330,7 +330,7 @@ var healthCheck = new HealthCheck // Healthy if 1 or more of 3 probes succeed ``` -#### HealthCheckProbePolicy.AllSuccess +#### HealthCheckProbePolicy.AllSuccess (Default) The health check passes only if **all** probes succeed. This provides the strictest evaluation: diff --git a/docs/Configuration.md b/docs/Configuration.md index ce3ab93f6f..8e51f4146b 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -128,6 +128,20 @@ Additional code-only options: - **Note: heartbeats are not free and that's why the default is 1 second. There is additional overhead to running this more often simply because it does some work each time it fires.** - LibraryName - Default: `SE.Redis` (unless a `DefaultOptionsProvider` specifies otherwise) - The library name to use with `CLIENT SETINFO` when setting the library name/version on the connection +- IncludeDetailInExceptions - Default: `true` + - Whether exceptions include identifiable details (key names, and additional `.Data` annotations) +- Defaults (`DefaultOptionsProvider`) - Default: resolved from the configured endpoints + - The provider that supplies default values for options that have not been set explicitly (for example, recognized cloud endpoints can apply tuned defaults) +- IncludePerformanceCountersInExceptions - Default: `false` + - Whether exceptions include performance counter details (CPU usage, etc); note that this can be problematic on some platforms +- RequestBufferPool (`MemoryPool`) - Default: `null` + - The buffer pool to use when buffering requests; when `null`, a shared default pool is used +- ResponseBufferPool (`MemoryPool`) - Default: `null` + - The buffer pool to use when buffering responses (and for allocating `Lease` results); when `null`, a shared default pool is used +- CircuitBreaker (`CircuitBreaker`) - Default: `null` + - **[Experimental](exp/SER007)** (Active:Active). A per-connection circuit breaker that *passively* observes the outcome of normal traffic and tears the connection down when it becomes unstable. When the connection is a member of an Active:Active group, this flows in from `MultiGroupOptions.CircuitBreaker` if not set explicitly. See [Active:Active](ActiveActive) +- HealthCheck (`HealthCheck`) - Default: `null` + - **[Experimental](exp/SER007)** (Active:Active). An *active* health check used when the connection is a member of an Active:Active group; when `null`, the group-level `MultiGroupOptions.HealthCheck` is used. See [Active:Active](ActiveActive) Tokens in the configuration string are comma-separated; any without an `=` sign are assumed to be redis server endpoints. Endpoints without an explicit port will use 6379 if ssl is not enabled, and 6380 if ssl is enabled. Tokens starting with `$` are taken to represent command maps, for example: `$config=cfg`. diff --git a/src/StackExchange.Redis/Availability/HealthCheck.cs b/src/StackExchange.Redis/Availability/HealthCheck.cs index 7b13520349..8345ed01b5 100644 --- a/src/StackExchange.Redis/Availability/HealthCheck.cs +++ b/src/StackExchange.Redis/Availability/HealthCheck.cs @@ -52,12 +52,12 @@ private static HealthCheck CreateDefault() /// public HealthCheck() { - Interval = TimeSpan.FromSeconds(10); + Interval = TimeSpan.FromSeconds(5); ProbeCount = 3; - ProbeTimeout = TimeSpan.FromSeconds(2); - ProbeInterval = TimeSpan.FromSeconds(1); + ProbeTimeout = TimeSpan.FromSeconds(3); + ProbeInterval = TimeSpan.FromMilliseconds(500); Probe = HealthCheckProbe.Ping; - ProbePolicy = HealthCheckProbePolicy.AnySuccess; + ProbePolicy = HealthCheckProbePolicy.AllSuccess; } /// From e43d1dd460a49d69f244ee88bc9455bc328befcf Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 10 Jul 2026 12:55:27 +0100 Subject: [PATCH 11/33] allow adding a node without checking health --- .../Availability/MultiGroupMultiplexer.cs | 78 ++++++++++++++++--- .../PublicAPI/PublicAPI.Unshipped.txt | 2 + 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs index ce61a1f580..1c1e6ca1f0 100644 --- a/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs @@ -85,7 +85,31 @@ public ConnectionGroupMember(string configuration, string name = "") : this( internal ConfigurationOptions Configuration => configuration; - private int _activated; // each member can only be activated once + // all of the simple boolean state for a member is packed into a single flags field, updated atomically + [Flags] + private enum MemberFlags + { + None = 0, + Activated = 1 << 0, // attached to a group; set exactly once (see Init) + Connected = 1 << 1, // last observed health state (see IsConnected) + ExplicitOverrideFlag = 1 << 2, // manual failover target (see ExplicitOverride) + SkipInitialHealthCheck = 1 << 3, // see SkipInitialHealthCheck + } + + private int _flags; + + private bool GetFlag(MemberFlags flag) => (Volatile.Read(ref _flags) & (int)flag) != 0; + + private void SetFlag(MemberFlags flag, bool value) + { + int set = value ? (int)flag : 0, clear = value ? 0 : (int)flag; + while (true) + { + int old = Volatile.Read(ref _flags); + int updated = (old & ~clear) | set; + if (updated == old || Interlocked.CompareExchange(ref _flags, updated, old) == old) return; + } + } /// public override string ToString() => Name; @@ -120,18 +144,40 @@ internal void Init(int index) } } - // check not already attached - if (Interlocked.CompareExchange(ref _activated, 1, 0) != 0) + // check not already attached (atomic test-and-set of the Activated flag) + while (true) { - throw new InvalidOperationException( - $"Member '{Name}' is already associated with a group, and cannot be reused."); + int old = Volatile.Read(ref _flags); + if ((old & (int)MemberFlags.Activated) != 0) + { + throw new InvalidOperationException( + $"Member '{Name}' is already associated with a group, and cannot be reused."); + } + + if (Interlocked.CompareExchange(ref _flags, old | (int)MemberFlags.Activated, old) == old) break; } } /// /// Indicates whether this group is currently connected. /// - public bool IsConnected { get; private set; } + public bool IsConnected + { + get => GetFlag(MemberFlags.Connected); + private set => SetFlag(MemberFlags.Connected, value); + } + + /// + /// Indicates whether the initial health-check should be skipped when this member is added to a group. + /// When true, the member is added immediately - as not-yet-connected - and only becomes selectable + /// once it subsequently passes a health-check, rather than blocking the add on an initial probe. This + /// allows adding a member that is not yet healthy. + /// + public bool SkipInitialHealthCheck + { + get => GetFlag(MemberFlags.SkipInitialHealthCheck); + set => SetFlag(MemberFlags.SkipInitialHealthCheck, value); + } /// /// The name of this group member. @@ -149,7 +195,11 @@ public double Weight set => Interlocked.Exchange(ref _weight64, BitConverter.DoubleToInt64Bits(value)); } - internal bool ExplicitOverride { get; set; } + internal bool ExplicitOverride + { + get => GetFlag(MemberFlags.ExplicitOverrideFlag); + set => SetFlag(MemberFlags.ExplicitOverrideFlag, value); + } private long _weight64 = BitConverter.DoubleToInt64Bits(1.0); @@ -1094,13 +1144,21 @@ public async Task AddAsync(ConnectionGroupMember member, TextWriter? log = null) { // connect member.Init(_members.Length); + member.Configuration.AbortOnConnectFail = false; // members are gated by health-checks, not connect-fail member.Configuration.HeartbeatConsistencyChecks = true; member.Configuration.CircuitBreaker ??= _options.CircuitBreaker; // AA options flow into the children var muxer = await ConnectionMultiplexer.ConnectAsync(member.Configuration, log).ConfigureAwait(false); member.SetMultiplexer(muxer); - var health = await (muxer.RawConfig.HealthCheck ?? _options.HealthCheck) - .CheckHealthAsync(muxer).ConfigureAwait(false); - member.UpdateState(health); + + // unless told otherwise, run an initial health-check so a healthy member can be selected immediately; + // when skipped, the member is added as not-yet-connected and the poll loop brings it online once it + // passes - this is the only way to add a member that is not yet healthy + if (!member.SkipInitialHealthCheck) + { + var health = await (muxer.RawConfig.HealthCheck ?? _options.HealthCheck) + .CheckHealthAsync(muxer).ConfigureAwait(false); + member.UpdateState(health); + } // apply any shared hooks AddEventHandlers(muxer); // includes circuit-breaker diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index f7a389601d..ed6590557b 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -71,6 +71,8 @@ static StackExchange.Redis.Configuration.LoggingTunnel.ValidateAsync(System.IO.S [SER007]StackExchange.Redis.Availability.ConnectionGroupMember.IsConnected.get -> bool [SER007]StackExchange.Redis.Availability.ConnectionGroupMember.Latency.get -> System.TimeSpan [SER007]StackExchange.Redis.Availability.ConnectionGroupMember.Name.get -> string! +[SER007]StackExchange.Redis.Availability.ConnectionGroupMember.SkipInitialHealthCheck.get -> bool +[SER007]StackExchange.Redis.Availability.ConnectionGroupMember.SkipInitialHealthCheck.set -> void [SER007]StackExchange.Redis.Availability.ConnectionGroupMember.Weight.get -> double [SER007]StackExchange.Redis.Availability.ConnectionGroupMember.Weight.set -> void [SER007]StackExchange.Redis.Availability.GroupConnectionChangedEventArgs From 797b838f4bd41847d6e32f88b41df05f7eb9a15e Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 10 Jul 2026 13:07:01 +0100 Subject: [PATCH 12/33] prefer Volatile for reading counters --- .../Availability/MultiGroupMultiplexer.cs | 10 +++++----- src/StackExchange.Redis/ConnectionMultiplexer.cs | 6 +++--- src/StackExchange.Redis/PhysicalBridge.cs | 16 ++++++++-------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs index 1c1e6ca1f0..4312134658 100644 --- a/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs @@ -189,10 +189,10 @@ public bool SkipInitialHealthCheck /// public double Weight { - // avoid "tearing", since we can't rule out this being updated concurrently, and the runtime - // only guarantees atomicity for 32-bit reads/writes - get => BitConverter.Int64BitsToDouble(Interlocked.Read(ref _weight64)); - set => Interlocked.Exchange(ref _weight64, BitConverter.DoubleToInt64Bits(value)); + // avoid "tearing", since we can't rule out this being updated concurrently: Volatile.Read/Write + // on a double are atomic even on 32-bit processors, so no read-modify-write dance is needed + get => Volatile.Read(ref _weight); + set => Volatile.Write(ref _weight, value); } internal bool ExplicitOverride @@ -201,7 +201,7 @@ internal bool ExplicitOverride set => SetFlag(MemberFlags.ExplicitOverrideFlag, value); } - private long _weight64 = BitConverter.DoubleToInt64Bits(1.0); + private double _weight = 1.0; /// /// The measured latency to this member. diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index 891e36fec8..769a64b50f 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -1362,9 +1362,9 @@ internal void GetStatus(ILogger? log) log.LogInformationServerSummary(server.Summary(), server.GetCounters(), server.GetProfile()); } log.LogInformationTimeoutsSummary( - Interlocked.Read(ref syncTimeouts), - Interlocked.Read(ref asyncTimeouts), - Interlocked.Read(ref fireAndForgets), + Volatile.Read(ref syncTimeouts), + Volatile.Read(ref asyncTimeouts), + Volatile.Read(ref fireAndForgets), LastHeartbeatSecondsAgo); } diff --git a/src/StackExchange.Redis/PhysicalBridge.cs b/src/StackExchange.Redis/PhysicalBridge.cs index de9edb7de6..1172585173 100644 --- a/src/StackExchange.Redis/PhysicalBridge.cs +++ b/src/StackExchange.Redis/PhysicalBridge.cs @@ -107,7 +107,7 @@ public enum State : byte internal State ConnectionState => (State)state; internal bool IsBeating => Interlocked.CompareExchange(ref beating, 0, 0) == 1; - internal long OperationCount => Interlocked.Read(ref operationCount); + internal long OperationCount => Volatile.Read(ref operationCount); public RedisCommand LastCommand { get; private set; } @@ -259,9 +259,9 @@ internal void AppendProfile(StringBuilder sb) var clone = new long[ProfileLogSamples + 1]; for (int i = 0; i < ProfileLogSamples; i++) { - clone[i] = Interlocked.Read(ref profileLog[i]); + clone[i] = Volatile.Read(ref profileLog[i]); } - clone[ProfileLogSamples] = Interlocked.Read(ref operationCount); + clone[ProfileLogSamples] = Volatile.Read(ref operationCount); Array.Sort(clone); sb.Append(' ').Append(clone[0]); for (int i = 1; i < clone.Length; i++) @@ -282,9 +282,9 @@ internal void AppendProfile(StringBuilder sb) internal void GetCounters(ConnectionCounters counters) { counters.OperationCount = OperationCount; - counters.SocketCount = Interlocked.Read(ref socketCount); + counters.SocketCount = Volatile.Read(ref socketCount); counters.WriterCount = Interlocked.CompareExchange(ref activeWriters, 0, 0); - counters.NonPreferredEndpointCount = Interlocked.Read(ref nonPreferredEndpointCount); + counters.NonPreferredEndpointCount = Volatile.Read(ref nonPreferredEndpointCount); counters.PendingUnsentItems = Volatile.Read(ref _backlogCurrentEnqueued); physical?.GetCounters(counters); } @@ -342,7 +342,7 @@ public override string ToString() => internal BridgeStatus GetStatus() => new() { - MessagesSinceLastHeartbeat = (int)(Interlocked.Read(ref operationCount) - Interlocked.Read(ref profileLastLog)), + MessagesSinceLastHeartbeat = (int)(Volatile.Read(ref operationCount) - Volatile.Read(ref profileLastLog)), ConnectedAt = ConnectedAt, IsWriterActive = !_singleWriter.IsAvailable, BacklogMessagesPending = _backlog.Count, @@ -551,7 +551,7 @@ internal void OnFullyEstablished(PhysicalConnection connection, string source) private bool DueForConnectRetry() { int connectTimeMilliseconds = unchecked(Environment.TickCount - Volatile.Read(ref connectStartTicks)); - return Multiplexer.RawConfig.ReconnectRetryPolicy.ShouldRetry(Interlocked.Read(ref connectTimeoutRetryCount), connectTimeMilliseconds); + return Multiplexer.RawConfig.ReconnectRetryPolicy.ShouldRetry(Volatile.Read(ref connectTimeoutRetryCount), connectTimeMilliseconds); } internal void OnHeartbeat(bool ifConnectedOnly) @@ -572,7 +572,7 @@ internal void OnHeartbeat(bool ifConnectedOnly) if (!runThisTime) return; uint index = (uint)Interlocked.Increment(ref profileLogIndex); - long newSampleCount = Interlocked.Read(ref operationCount); + long newSampleCount = Volatile.Read(ref operationCount); Interlocked.Exchange(ref profileLog[index % ProfileLogSamples], newSampleCount); Interlocked.Exchange(ref profileLastLog, newSampleCount); From 52a4514e8426bfbbc842c2dc1ba4698ac2f761b5 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 10 Jul 2026 13:17:57 +0100 Subject: [PATCH 13/33] more prefer volatile over interlocked reads --- src/StackExchange.Redis/PhysicalBridge.cs | 14 +++++----- .../BufferedStreamWriterTests.cs | 2 +- .../CircuitBreakerUnitTests.cs | 2 +- .../StackExchange.Redis.Tests/ClusterTests.cs | 4 +-- .../ConnectionShutdownTests.cs | 12 ++++----- .../FailoverTests.cs | 26 +++++++++---------- .../StackExchange.Redis.Tests/LockingTests.cs | 2 +- .../StackExchange.Redis.Tests/LoggerTests.cs | 2 +- .../StackExchange.Redis.Tests/PubSubTests.cs | 18 ++++++------- 9 files changed, 41 insertions(+), 41 deletions(-) diff --git a/src/StackExchange.Redis/PhysicalBridge.cs b/src/StackExchange.Redis/PhysicalBridge.cs index 1172585173..ca70965bb3 100644 --- a/src/StackExchange.Redis/PhysicalBridge.cs +++ b/src/StackExchange.Redis/PhysicalBridge.cs @@ -105,7 +105,7 @@ public enum State : byte public long SubscriptionCount => physical?.SubscriptionCount ?? 0; internal State ConnectionState => (State)state; - internal bool IsBeating => Interlocked.CompareExchange(ref beating, 0, 0) == 1; + internal bool IsBeating => Volatile.Read(ref beating) == 1; internal long OperationCount => Volatile.Read(ref operationCount); @@ -283,7 +283,7 @@ internal void GetCounters(ConnectionCounters counters) { counters.OperationCount = OperationCount; counters.SocketCount = Volatile.Read(ref socketCount); - counters.WriterCount = Interlocked.CompareExchange(ref activeWriters, 0, 0); + counters.WriterCount = Volatile.Read(ref activeWriters); counters.NonPreferredEndpointCount = Volatile.Read(ref nonPreferredEndpointCount); counters.PendingUnsentItems = Volatile.Read(ref _backlogCurrentEnqueued); physical?.GetCounters(counters); @@ -573,8 +573,8 @@ internal void OnHeartbeat(bool ifConnectedOnly) uint index = (uint)Interlocked.Increment(ref profileLogIndex); long newSampleCount = Volatile.Read(ref operationCount); - Interlocked.Exchange(ref profileLog[index % ProfileLogSamples], newSampleCount); - Interlocked.Exchange(ref profileLastLog, newSampleCount); + Volatile.Write(ref profileLog[index % ProfileLogSamples], newSampleCount); + Volatile.Write(ref profileLastLog, newSampleCount); #pragma warning disable CS0420 // A reference to a volatile field will not be treated as volatile // ReSharper disable once LocalVariableHidesMember @@ -613,7 +613,7 @@ internal void OnHeartbeat(bool ifConnectedOnly) // Track that we should reset the count on the next disconnect, but not do so in a loop, reset // the connect-retry-count (used for backoff decay etc), and remove any non-responsive flag. shouldResetConnectionRetryCount = true; - Interlocked.Exchange(ref connectTimeoutRetryCount, 0); + Volatile.Write(ref connectTimeoutRetryCount, 0); tmp.BridgeCouldBeNull?.ServerEndPoint?.ClearUnselectable(UnselectableFlags.DidNotRespond); } tmp.OnBridgeHeartbeat(out int asyncTimeoutThisHeartbeat, out int syncTimeoutThisHeartbeat); @@ -687,7 +687,7 @@ internal void OnHeartbeat(bool ifConnectedOnly) if (shouldResetConnectionRetryCount) { shouldResetConnectionRetryCount = false; - Interlocked.Exchange(ref connectTimeoutRetryCount, 0); + Volatile.Write(ref connectTimeoutRetryCount, 0); } if (!ifConnectedOnly && DueForConnectRetry()) { @@ -700,7 +700,7 @@ internal void OnHeartbeat(bool ifConnectedOnly) } break; default: - Interlocked.Exchange(ref connectTimeoutRetryCount, 0); + Volatile.Write(ref connectTimeoutRetryCount, 0); break; } } diff --git a/tests/StackExchange.Redis.Tests/BufferedStreamWriterTests.cs b/tests/StackExchange.Redis.Tests/BufferedStreamWriterTests.cs index 932153db0e..e1c9bceeb3 100644 --- a/tests/StackExchange.Redis.Tests/BufferedStreamWriterTests.cs +++ b/tests/StackExchange.Redis.Tests/BufferedStreamWriterTests.cs @@ -215,7 +215,7 @@ private sealed class ObservedStream : Stream private TaskCompletionSource? _blockedFlush, _allowFlush; public Exception? WriteException { get; set; } - public long BytesWritten => Interlocked.Read(ref _bytesWritten); + public long BytesWritten => Volatile.Read(ref _bytesWritten); public int SyncWriteCount => Volatile.Read(ref _syncWriteCount); public int AsyncWriteCount => Volatile.Read(ref _asyncWriteCount); public byte[] WrittenBytes diff --git a/tests/StackExchange.Redis.Tests/CircuitBreakerUnitTests.cs b/tests/StackExchange.Redis.Tests/CircuitBreakerUnitTests.cs index a7c25a4888..dad3dd015d 100644 --- a/tests/StackExchange.Redis.Tests/CircuitBreakerUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/CircuitBreakerUnitTests.cs @@ -216,7 +216,7 @@ private sealed class ManualTimeProvider : TimeProvider // one tick == 100ns, matching TimeSpan; keeps Advance(TimeSpan) a straight addition public override long TimestampFrequency => TimeSpan.TicksPerSecond; - public override long GetTimestamp() => Interlocked.Read(ref _timestamp); + public override long GetTimestamp() => Volatile.Read(ref _timestamp); public void Advance(TimeSpan by) => Interlocked.Add(ref _timestamp, by.Ticks); } diff --git a/tests/StackExchange.Redis.Tests/ClusterTests.cs b/tests/StackExchange.Redis.Tests/ClusterTests.cs index 118e0f9758..af58346e93 100644 --- a/tests/StackExchange.Redis.Tests/ClusterTests.cs +++ b/tests/StackExchange.Redis.Tests/ClusterTests.cs @@ -689,9 +689,9 @@ public async Task AccessRandomKeys() var counters = server.GetCounters(); Log(counters.ToString()); } - int final = Interlocked.CompareExchange(ref total, 0, 0); + int final = Volatile.Read(ref total); Assert.Equal(COUNT, final); - Assert.Equal(0, Interlocked.CompareExchange(ref slotMovedCount, 0, 0)); + Assert.Equal(0, Volatile.Read(ref slotMovedCount)); } [Theory] diff --git a/tests/StackExchange.Redis.Tests/ConnectionShutdownTests.cs b/tests/StackExchange.Redis.Tests/ConnectionShutdownTests.cs index 6fdc90cea1..c05ecdec9f 100644 --- a/tests/StackExchange.Redis.Tests/ConnectionShutdownTests.cs +++ b/tests/StackExchange.Redis.Tests/ConnectionShutdownTests.cs @@ -27,8 +27,8 @@ public async Task ShutdownRaisesConnectionFailureAndRestore() }; var db = conn.GetDatabase(); await db.PingAsync(); - Assert.Equal(0, Interlocked.CompareExchange(ref failed, 0, 0)); - Assert.Equal(0, Interlocked.CompareExchange(ref restored, 0, 0)); + Assert.Equal(0, Volatile.Read(ref failed)); + Assert.Equal(0, Volatile.Read(ref restored)); await Task.Delay(1).ForAwait(); // To make compiler happy in Release conn.AllowConnect = false; @@ -39,13 +39,13 @@ public async Task ShutdownRaisesConnectionFailureAndRestore() db.Ping(CommandFlags.FireAndForget); await Task.Delay(250).ForAwait(); - Assert.Equal(2, Interlocked.CompareExchange(ref failed, 0, 0)); - Assert.Equal(0, Interlocked.CompareExchange(ref restored, 0, 0)); + Assert.Equal(2, Volatile.Read(ref failed)); + Assert.Equal(0, Volatile.Read(ref restored)); conn.AllowConnect = true; db.Ping(CommandFlags.FireAndForget); await Task.Delay(1500).ForAwait(); - Assert.Equal(2, Interlocked.CompareExchange(ref failed, 0, 0)); - Assert.Equal(2, Interlocked.CompareExchange(ref restored, 0, 0)); + Assert.Equal(2, Volatile.Read(ref failed)); + Assert.Equal(2, Volatile.Read(ref restored)); watch.Stop(); } } diff --git a/tests/StackExchange.Redis.Tests/FailoverTests.cs b/tests/StackExchange.Redis.Tests/FailoverTests.cs index e1222394c3..49c15c7451 100644 --- a/tests/StackExchange.Redis.Tests/FailoverTests.cs +++ b/tests/StackExchange.Redis.Tests/FailoverTests.cs @@ -89,7 +89,7 @@ public async Task ConfigVerifyReceiveConfigChangeBroadcast() await GetServer(receiverConn).PingAsync(); await Task.Delay(1000).ConfigureAwait(false); Assert.True(count == -1 || count >= 2, "subscribers"); - Assert.True(Interlocked.CompareExchange(ref total, 0, 0) >= 1, "total (1st)"); + Assert.True(Volatile.Read(ref total) >= 1, "total (1st)"); Interlocked.Exchange(ref total, 0); @@ -100,7 +100,7 @@ public async Task ConfigVerifyReceiveConfigChangeBroadcast() await Task.Delay(1000).ConfigureAwait(false); await GetServer(receiverConn).PingAsync(); await GetServer(receiverConn).PingAsync(); - Assert.True(Interlocked.CompareExchange(ref total, 0, 0) >= 1, "total (2nd)"); + Assert.True(Volatile.Read(ref total) >= 1, "total (2nd)"); } [Fact] @@ -339,11 +339,11 @@ public async Task SubscriptionsSurvivePrimarySwitchAsync() Log(" SubA ping: " + subA.Ping()); Log(" SubB ping: " + subB.Ping()); // If redis is under load due to this suite, it may take a moment to send across. - await UntilConditionAsync(TimeSpan.FromSeconds(5), () => Interlocked.Read(ref aCount) == 2 && Interlocked.Read(ref bCount) == 2).ForAwait(); + await UntilConditionAsync(TimeSpan.FromSeconds(5), () => Volatile.Read(ref aCount) == 2 && Volatile.Read(ref bCount) == 2).ForAwait(); - Assert.Equal(2, Interlocked.Read(ref aCount)); - Assert.Equal(2, Interlocked.Read(ref bCount)); - Assert.Equal(0, Interlocked.Read(ref primaryChanged)); + Assert.Equal(2, Volatile.Read(ref aCount)); + Assert.Equal(2, Volatile.Read(ref bCount)); + Assert.Equal(0, Volatile.Read(ref primaryChanged)); try { @@ -410,17 +410,17 @@ public async Task SubscriptionsSurvivePrimarySwitchAsync() await subA.PingAsync(); await subB.PingAsync(); Log("Ping Complete. Checking..."); - await UntilConditionAsync(TimeSpan.FromSeconds(10), () => Interlocked.Read(ref aCount) == 2 && Interlocked.Read(ref bCount) == 2).ForAwait(); + await UntilConditionAsync(TimeSpan.FromSeconds(10), () => Volatile.Read(ref aCount) == 2 && Volatile.Read(ref bCount) == 2).ForAwait(); Log("Counts so far:"); - Log(" aCount: " + Interlocked.Read(ref aCount)); - Log(" bCount: " + Interlocked.Read(ref bCount)); - Log(" primaryChanged: " + Interlocked.Read(ref primaryChanged)); + Log(" aCount: " + Volatile.Read(ref aCount)); + Log(" bCount: " + Volatile.Read(ref bCount)); + Log(" primaryChanged: " + Volatile.Read(ref primaryChanged)); - Assert.Equal(2, Interlocked.Read(ref aCount)); - Assert.Equal(2, Interlocked.Read(ref bCount)); + Assert.Equal(2, Volatile.Read(ref aCount)); + Assert.Equal(2, Volatile.Read(ref bCount)); // Expect 12, because a sees a, but b sees a and b due to replication, but contenders may add their own - Assert.True(Interlocked.CompareExchange(ref primaryChanged, 0, 0) >= 12); + Assert.True(Volatile.Read(ref primaryChanged) >= 12); } catch { diff --git a/tests/StackExchange.Redis.Tests/LockingTests.cs b/tests/StackExchange.Redis.Tests/LockingTests.cs index 52d03bb831..52d498a25d 100644 --- a/tests/StackExchange.Redis.Tests/LockingTests.cs +++ b/tests/StackExchange.Redis.Tests/LockingTests.cs @@ -58,7 +58,7 @@ void Inner(object? obj) ThreadPool.QueueUserWorkItem(Inner, conn2.GetDatabase(db)); evt.WaitOne(8000); } - Assert.Equal(0, Interlocked.CompareExchange(ref errorCount, 0, 0)); + Assert.Equal(0, Volatile.Read(ref errorCount)); Assert.Equal(0, bgErrorCount); } diff --git a/tests/StackExchange.Redis.Tests/LoggerTests.cs b/tests/StackExchange.Redis.Tests/LoggerTests.cs index 31ed3fae05..6839eea5e7 100644 --- a/tests/StackExchange.Redis.Tests/LoggerTests.cs +++ b/tests/StackExchange.Redis.Tests/LoggerTests.cs @@ -137,7 +137,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except _output.WriteLine(logLine); } - public long CallCount => Interlocked.Read(ref _callCount); + public long CallCount => Volatile.Read(ref _callCount); public override string ToString() { lock (sb) diff --git a/tests/StackExchange.Redis.Tests/PubSubTests.cs b/tests/StackExchange.Redis.Tests/PubSubTests.cs index 65e1ee5741..b4ba7b5de7 100644 --- a/tests/StackExchange.Redis.Tests/PubSubTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubTests.cs @@ -720,16 +720,16 @@ public async Task TestMultipleSubscribersGetMessage() await Task.WhenAll(tA, tB).ForAwait(); Assert.Equal(2, pub.Publish(channel, "message")); await AllowReasonableTimeToPublishAndProcess().ForAwait(); - Assert.Equal(1, Interlocked.CompareExchange(ref gotA, 0, 0)); - Assert.Equal(1, Interlocked.CompareExchange(ref gotB, 0, 0)); + Assert.Equal(1, Volatile.Read(ref gotA)); + Assert.Equal(1, Volatile.Read(ref gotB)); // and unsubscribe... tA = listenA.UnsubscribeAsync(channel); await tA; Assert.Equal(1, pub.Publish(channel, "message")); await AllowReasonableTimeToPublishAndProcess().ForAwait(); - Assert.Equal(1, Interlocked.CompareExchange(ref gotA, 0, 0)); - Assert.Equal(2, Interlocked.CompareExchange(ref gotB, 0, 0)); + Assert.Equal(1, Volatile.Read(ref gotA)); + Assert.Equal(2, Volatile.Read(ref gotB)); } [Fact] @@ -762,7 +762,7 @@ public async Task Issue38() await AllowReasonableTimeToPublishAndProcess().ForAwait(); Assert.Equal(6, total); // sent - Assert.Equal(6, Interlocked.CompareExchange(ref count, 0, 0)); // received + Assert.Equal(6, Volatile.Read(ref count)); // received } internal static Task AllowReasonableTimeToPublishAndProcess() => Task.Delay(500); @@ -787,8 +787,8 @@ public async Task TestPartialSubscriberGetMessage() Assert.Equal(2, pub.Publish(prefix + "channel", "message")); #pragma warning restore CS0618 await AllowReasonableTimeToPublishAndProcess().ForAwait(); - Assert.Equal(1, Interlocked.CompareExchange(ref gotA, 0, 0)); - Assert.Equal(1, Interlocked.CompareExchange(ref gotB, 0, 0)); + Assert.Equal(1, Volatile.Read(ref gotA)); + Assert.Equal(1, Volatile.Read(ref gotB)); // and unsubscibe... #pragma warning disable CS0618 @@ -797,8 +797,8 @@ public async Task TestPartialSubscriberGetMessage() Assert.Equal(1, pub.Publish(prefix + "channel", "message")); #pragma warning restore CS0618 await AllowReasonableTimeToPublishAndProcess().ForAwait(); - Assert.Equal(2, Interlocked.CompareExchange(ref gotA, 0, 0)); - Assert.Equal(1, Interlocked.CompareExchange(ref gotB, 0, 0)); + Assert.Equal(2, Volatile.Read(ref gotA)); + Assert.Equal(1, Volatile.Read(ref gotB)); } [Fact] From 9e79dc405fc37b147b6c3c863dfb02125a5730a6 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 10 Jul 2026 13:22:06 +0100 Subject: [PATCH 14/33] more volatile --- tests/StackExchange.Redis.Tests/FailoverTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/StackExchange.Redis.Tests/FailoverTests.cs b/tests/StackExchange.Redis.Tests/FailoverTests.cs index 49c15c7451..da470631cf 100644 --- a/tests/StackExchange.Redis.Tests/FailoverTests.cs +++ b/tests/StackExchange.Redis.Tests/FailoverTests.cs @@ -347,9 +347,9 @@ public async Task SubscriptionsSurvivePrimarySwitchAsync() try { - Interlocked.Exchange(ref primaryChanged, 0); - Interlocked.Exchange(ref aCount, 0); - Interlocked.Exchange(ref bCount, 0); + Volatile.Write(ref primaryChanged, 0); + Volatile.Write(ref aCount, 0); + Volatile.Write(ref bCount, 0); Log("Changing primary..."); using (var sw = new StringWriter()) { From 6e21e95e1fc594a8edf8a226e9267df3f2961699 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 10 Jul 2026 13:34:34 +0100 Subject: [PATCH 15/33] observe that LoggingTunnel is actually shipped --- .../PublicAPI/PublicAPI.Shipped.txt | 40 ++++++++++++++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 42 +------------------ .../PublicAPI/net6.0/PublicAPI.Shipped.txt | 5 +++ .../PublicAPI/net6.0/PublicAPI.Unshipped.txt | 5 --- 4 files changed, 46 insertions(+), 46 deletions(-) diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt index 0f9e623f40..865cea8f5f 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt @@ -2542,3 +2542,43 @@ static StackExchange.Redis.Lease.Create(int length, System.Buffers.MemoryPool static StackExchange.Redis.RedisValue.implicit operator StackExchange.Redis.RedisValue(System.Buffers.ReadOnlySequence value) -> StackExchange.Redis.RedisValue static StackExchange.Redis.RedisValue.implicit operator System.Buffers.ReadOnlySequence(StackExchange.Redis.RedisValue value) -> System.Buffers.ReadOnlySequence [SER002]static StackExchange.Redis.ValueCondition.CalculateDigest(in System.Buffers.ReadOnlySequence value) -> StackExchange.Redis.ValueCondition +abstract StackExchange.Redis.Configuration.LoggingTunnel.Log(System.IO.Stream! stream, System.Net.EndPoint! endpoint, StackExchange.Redis.ConnectionType connectionType) -> System.IO.Stream! +override StackExchange.Redis.Configuration.LoggingTunnel.BeforeAuthenticateAsync(System.Net.EndPoint! endpoint, StackExchange.Redis.ConnectionType connectionType, System.Net.Sockets.Socket? socket, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +override StackExchange.Redis.Configuration.LoggingTunnel.BeforeSocketConnectAsync(System.Net.EndPoint! endPoint, StackExchange.Redis.ConnectionType connectionType, System.Net.Sockets.Socket? socket, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +override StackExchange.Redis.Configuration.LoggingTunnel.GetSocketConnectEndpointAsync(System.Net.EndPoint! endpoint, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.BeginRead(byte[]! buffer, int offset, int count, System.AsyncCallback? callback, object? state) -> System.IAsyncResult! +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.BeginWrite(byte[]! buffer, int offset, int count, System.AsyncCallback? callback, object? state) -> System.IAsyncResult! +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.CanRead.get -> bool +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.CanSeek.get -> bool +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.CanTimeout.get -> bool +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.CanWrite.get -> bool +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Close() -> void +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.EndRead(System.IAsyncResult! asyncResult) -> int +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.EndWrite(System.IAsyncResult! asyncResult) -> void +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Flush() -> void +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.FlushAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Length.get -> long +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Position.get -> long +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Position.set -> void +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Read(byte[]! buffer, int offset, int count) -> int +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.ReadAsync(byte[]! buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.ReadByte() -> int +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.ReadTimeout.get -> int +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.ReadTimeout.set -> void +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Seek(long offset, System.IO.SeekOrigin origin) -> long +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.SetLength(long value) -> void +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Write(byte[]! buffer, int offset, int count) -> void +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.WriteAsync(byte[]! buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.WriteByte(byte value) -> void +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.WriteTimeout.get -> int +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.WriteTimeout.set -> void +StackExchange.Redis.Configuration.LoggingTunnel +StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream +StackExchange.Redis.Configuration.LoggingTunnel.LoggingTunnel(StackExchange.Redis.ConfigurationOptions? options = null, StackExchange.Redis.Configuration.Tunnel? tail = null) -> void +static StackExchange.Redis.Configuration.LoggingTunnel.DefaultFormatCommand(StackExchange.Redis.RedisResult! value) -> string! +static StackExchange.Redis.Configuration.LoggingTunnel.DefaultFormatResponse(StackExchange.Redis.RedisResult! value) -> string! +static StackExchange.Redis.Configuration.LoggingTunnel.LogToDirectory(StackExchange.Redis.ConfigurationOptions! options, string! path) -> void +static StackExchange.Redis.Configuration.LoggingTunnel.ReplayAsync(string! path, System.Action! pair) -> System.Threading.Tasks.Task! +static StackExchange.Redis.Configuration.LoggingTunnel.ReplayAsync(System.IO.Stream! out, System.IO.Stream! in, System.Action! pair) -> System.Threading.Tasks.Task! +static StackExchange.Redis.Configuration.LoggingTunnel.ValidateAsync(string! path) -> System.Threading.Tasks.Task! +static StackExchange.Redis.Configuration.LoggingTunnel.ValidateAsync(System.IO.Stream! stream) -> System.Threading.Tasks.Task! diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index ed6590557b..b28ef249d7 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,45 +1,5 @@ #nullable enable -abstract StackExchange.Redis.Configuration.LoggingTunnel.Log(System.IO.Stream! stream, System.Net.EndPoint! endpoint, StackExchange.Redis.ConnectionType connectionType) -> System.IO.Stream! -override StackExchange.Redis.Configuration.LoggingTunnel.BeforeAuthenticateAsync(System.Net.EndPoint! endpoint, StackExchange.Redis.ConnectionType connectionType, System.Net.Sockets.Socket? socket, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -override StackExchange.Redis.Configuration.LoggingTunnel.BeforeSocketConnectAsync(System.Net.EndPoint! endPoint, StackExchange.Redis.ConnectionType connectionType, System.Net.Sockets.Socket? socket, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -override StackExchange.Redis.Configuration.LoggingTunnel.GetSocketConnectEndpointAsync(System.Net.EndPoint! endpoint, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.BeginRead(byte[]! buffer, int offset, int count, System.AsyncCallback? callback, object? state) -> System.IAsyncResult! -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.BeginWrite(byte[]! buffer, int offset, int count, System.AsyncCallback? callback, object? state) -> System.IAsyncResult! -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.CanRead.get -> bool -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.CanSeek.get -> bool -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.CanTimeout.get -> bool -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.CanWrite.get -> bool -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Close() -> void -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.EndRead(System.IAsyncResult! asyncResult) -> int -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.EndWrite(System.IAsyncResult! asyncResult) -> void -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Flush() -> void -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.FlushAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Length.get -> long -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Position.get -> long -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Position.set -> void -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Read(byte[]! buffer, int offset, int count) -> int -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.ReadAsync(byte[]! buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.ReadByte() -> int -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.ReadTimeout.get -> int -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.ReadTimeout.set -> void -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Seek(long offset, System.IO.SeekOrigin origin) -> long -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.SetLength(long value) -> void -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Write(byte[]! buffer, int offset, int count) -> void -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.WriteAsync(byte[]! buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.WriteByte(byte value) -> void -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.WriteTimeout.get -> int -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.WriteTimeout.set -> void -StackExchange.Redis.Configuration.LoggingTunnel -StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream -StackExchange.Redis.Configuration.LoggingTunnel.LoggingTunnel(StackExchange.Redis.ConfigurationOptions? options = null, StackExchange.Redis.Configuration.Tunnel? tail = null) -> void StackExchange.Redis.IServer.InventKey(StackExchange.Redis.RedisKey prefix = default(StackExchange.Redis.RedisKey)) -> StackExchange.Redis.RedisKey -static StackExchange.Redis.Configuration.LoggingTunnel.DefaultFormatCommand(StackExchange.Redis.RedisResult! value) -> string! -static StackExchange.Redis.Configuration.LoggingTunnel.DefaultFormatResponse(StackExchange.Redis.RedisResult! value) -> string! -static StackExchange.Redis.Configuration.LoggingTunnel.LogToDirectory(StackExchange.Redis.ConfigurationOptions! options, string! path) -> void -static StackExchange.Redis.Configuration.LoggingTunnel.ReplayAsync(string! path, System.Action! pair) -> System.Threading.Tasks.Task! -static StackExchange.Redis.Configuration.LoggingTunnel.ReplayAsync(System.IO.Stream! out, System.IO.Stream! in, System.Action! pair) -> System.Threading.Tasks.Task! -static StackExchange.Redis.Configuration.LoggingTunnel.ValidateAsync(string! path) -> System.Threading.Tasks.Task! -static StackExchange.Redis.Configuration.LoggingTunnel.ValidateAsync(System.IO.Stream! stream) -> System.Threading.Tasks.Task! [SER007]abstract StackExchange.Redis.Availability.CircuitBreaker.Accumulator.IsHealthy() -> bool [SER007]abstract StackExchange.Redis.Availability.CircuitBreaker.Accumulator.ObserveResult(in StackExchange.Redis.Availability.CircuitBreaker.CircuitBreakerContext context) -> bool [SER007]abstract StackExchange.Redis.Availability.CircuitBreaker.Accumulator.Reset() -> void @@ -160,4 +120,4 @@ static StackExchange.Redis.Configuration.LoggingTunnel.ValidateAsync(System.IO.S [SER007]StackExchange.Redis.ConfigurationOptions.CircuitBreaker.set -> void [SER007]StackExchange.Redis.ConfigurationOptions.HealthCheck.get -> StackExchange.Redis.Availability.HealthCheck? [SER007]StackExchange.Redis.ConfigurationOptions.HealthCheck.set -> void -[SER007]StackExchange.Redis.ConnectionFailureType.CircuitBreaker = 11 -> StackExchange.Redis.ConnectionFailureType \ No newline at end of file +[SER007]StackExchange.Redis.ConnectionFailureType.CircuitBreaker = 11 -> StackExchange.Redis.ConnectionFailureType diff --git a/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Shipped.txt index bec516e5c3..cec3e27424 100644 --- a/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Shipped.txt +++ b/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Shipped.txt @@ -3,3 +3,8 @@ StackExchange.Redis.ConfigurationOptions.SslClientAuthenticationOptions.get -> S StackExchange.Redis.ConfigurationOptions.SslClientAuthenticationOptions.set -> void System.Runtime.CompilerServices.IsExternalInit (forwarded, contained in System.Runtime) StackExchange.Redis.ConfigurationOptions.SetUserPemCertificate(string! userCertificatePath, string? userKeyPath = null) -> void +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.DisposeAsync() -> System.Threading.Tasks.ValueTask +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Read(System.Span buffer) -> int +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.ReadAsync(System.Memory buffer, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Write(System.ReadOnlySpan buffer) -> void +override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.WriteAsync(System.ReadOnlyMemory buffer, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask diff --git a/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Unshipped.txt index 85928bcdaa..ab058de62d 100644 --- a/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Unshipped.txt @@ -1,6 +1 @@ #nullable enable -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.DisposeAsync() -> System.Threading.Tasks.ValueTask -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Read(System.Span buffer) -> int -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.ReadAsync(System.Memory buffer, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.Write(System.ReadOnlySpan buffer) -> void -override StackExchange.Redis.Configuration.LoggingTunnel.LoggingDuplexStream.WriteAsync(System.ReadOnlyMemory buffer, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask \ No newline at end of file From f88aa183bd98a5d76b30a70592734ce16c2c43a6 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 10 Jul 2026 16:14:40 +0100 Subject: [PATCH 16/33] FailbackDelay --- docs/ActiveActive.md | 54 ++++++++++- .../Availability/MultiGroupMultiplexer.cs | 90 ++++++++++++++++--- .../Availability/MultiGroupOptions.cs | 13 +++ .../Interfaces/IConnectionGroup.cs | 2 +- .../PublicAPI/PublicAPI.Unshipped.txt | 4 + 5 files changed, 146 insertions(+), 17 deletions(-) diff --git a/docs/ActiveActive.md b/docs/ActiveActive.md index 0cc4f7de62..be926a44fc 100644 --- a/docs/ActiveActive.md +++ b/docs/ActiveActive.md @@ -189,6 +189,7 @@ foreach (var member in conn.GetMembers()) { Console.WriteLine($"{member.Name}:"); Console.WriteLine($" Connected: {member.IsConnected}"); + Console.WriteLine($" Unhealthy: {member.IsUnhealthy}"); Console.WriteLine($" Weight: {member.Weight}"); Console.WriteLine($" Latency: {member.Latency}"); } @@ -443,10 +444,10 @@ This policy ensures that transient successes don't immediately mark an endpoint ### Health Check Behavior When a health check fails for a member: -- The member's `IsConnected` property reflects the unhealthy state +- The member is flagged **unhealthy** (`IsUnhealthy`); this is distinct from `IsConnected` (see *Unhealthy State and Failback* below) - Traffic is automatically routed to other healthy members based on weight and latency - The system continues to perform health checks on the unhealthy member -- Once the member recovers and passes health checks, traffic automatically resumes +- Once the member recovers and passes health checks, traffic automatically resumes (subject to `FailbackDelay`) ### Best Practices @@ -584,6 +585,55 @@ var options = new MultiGroupOptions Keep `ObserveResult` cheap and thread-safe: it runs on the hot path for every completed message, and may be called concurrently. +## Unhealthy State and Failback + +Each member tracks two *independent* pieces of state: + +- **`IsConnected`** — the last observed connectivity of the underlying connection. +- **`IsUnhealthy`** — whether the member has been *disabled* by a failing health-check or a tripped circuit breaker. + +A member is only eligible to be selected as the active member when it is **connected _and_ not unhealthy**. Separating the two lets a member that is technically reconnected still be held out of rotation until we're confident it is stable again — which is what `FailbackDelay` controls. + +### How a member becomes unhealthy + +- A **health check** returns `Unhealthy` for it. +- Its **circuit breaker** trips (`ConnectionFailureType.CircuitBreaker`). The failing member is flagged unhealthy immediately on the circuit-breaker fast-path, so traffic routes away without waiting for the next health-check tick. + +Each time a member is (re)marked unhealthy, the time of that failure is recorded. + +### How a member becomes healthy again + +An unhealthy member is cleared in one of three ways: + +1. **Automatically**, once it passes a health check *and* its most recent failure is older than `FailbackDelay` (see below). +2. **Explicitly**, by calling `member.ResetIsUnhealthy()`. +3. **Implicitly**, by calling `IConnectionGroup.TryFailoverTo(member)` — an explicit failover request always clears the target's unhealthy flag first, since the caller is deliberately asking for that member. + +### `FailbackDelay` + +`MultiGroupOptions.FailbackDelay` is the interval a member must remain healthy — measured from its *most recent* failure — before it is automatically returned to rotation: + +```csharp +var options = new MultiGroupOptions +{ + FailbackDelay = TimeSpan.FromMinutes(2), // must be healthy for 2 minutes after its last failure +}; +``` + +| Value | Behavior | +|-------|----------| +| `TimeSpan.Zero` (default) | Immediate failback — the member is eligible again as soon as a health check passes | +| any positive `TimeSpan` | The member must go `FailbackDelay` with no further failures before it is re-selected | +| `TimeSpan.MaxValue` | **Manual mode** — automatic failback is disabled; the member stays out of rotation until `ResetIsUnhealthy()` or `TryFailoverTo(...)` is called | + +This guards against **flapping**: a member that is intermittently failing will keep pushing its "last failure" time forward, so it never satisfies the delay and stays out of rotation until it is genuinely stable. + +> Implementation note: the failback check is pure tick math on the wall clock — the last-failure time and the cutoff (`UtcNow - FailbackDelay`) are both compared as raw `long` UTC ticks, so `DateTimeKind` never enters into it. (`DateTime.Ticks` and `TimeSpan.Ticks` share the same 100 ns unit.) + +### Anti-flap tiebreak in selection + +Member selection ranks candidates by connectivity, explicit override, weight, and latency. When two candidates are otherwise indistinguishable, selection prefers the member that is **already active**, rather than picking arbitrarily. + ## Manual Failover In some scenarios, you may need to manually control which member is actively serving traffic, overriding the automatic selection based on weight and latency. The `TryFailoverTo` method allows you to explicitly switch to a specific member or restore automatic selection. diff --git a/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs index 4312134658..378489cd4f 100644 --- a/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs @@ -94,6 +94,7 @@ private enum MemberFlags Connected = 1 << 1, // last observed health state (see IsConnected) ExplicitOverrideFlag = 1 << 2, // manual failover target (see ExplicitOverride) SkipInitialHealthCheck = 1 << 3, // see SkipInitialHealthCheck + Unhealthy = 1 << 4, // disabled by a health-check or circuit-breaker } private int _flags; @@ -118,6 +119,27 @@ private void SetFlag(MemberFlags flag, bool value) internal ConnectionMultiplexer Multiplexer => _muxer ?? ThrowNoMuxer(); + internal void SetUnhealthy() + { + // stored as raw UTC ticks and only ever compared as a long against the failback cutoff; + // we never round-trip through a DateTime, so DateTimeKind never enters the picture + Volatile.Write(ref _lastUnhealthyTicks, DateTime.UtcNow.Ticks); + SetFlag(MemberFlags.Unhealthy, true); + } + + // UTC ticks (DateTime.Ticks and TimeSpan.Ticks share the same 100ns unit); long for atomicity + private long _lastUnhealthyTicks; + + /// + /// Clear the flag against this endpoint, allowing it to be reconsidered. + /// + public void ResetIsUnhealthy() => SetFlag(MemberFlags.Unhealthy, false); + + /// + /// Gets whether the endpoint failed a health-check or circuit-breaker test. + /// + public bool IsUnhealthy => GetFlag(MemberFlags.Unhealthy); + [DoesNotReturn] private static ConnectionMultiplexer ThrowNoMuxer() => throw new InvalidOperationException("Member is not connected."); @@ -209,6 +231,9 @@ internal bool ExplicitOverride public TimeSpan Latency => _latencyTicks is uint.MaxValue ? TimeSpan.MaxValue : TimeSpan.FromTicks(_latencyTicks); + internal bool ConsiderActive => // "IsConnected && !IsUnhealthy" + ((MemberFlags)Volatile.Read(ref _flags) & (MemberFlags.Connected | MemberFlags.Unhealthy)) is MemberFlags.Connected; + private uint _latencyTicks = uint.MaxValue; internal void SetLatency(uint ticks) => _latencyTicks = ticks; @@ -226,7 +251,7 @@ internal static uint ToLatencyTicks(TimeSpan latency) internal void SetLatency(TimeSpan latency) => SetLatency(ToLatencyTicks(latency)); - internal static ConnectionGroupMember? Select(ConnectionGroupMember? x, ConnectionGroupMember? y) + internal static ConnectionGroupMember? Select(ConnectionGroupMember? x, ConnectionGroupMember? y, ConnectionMultiplexer? active) { if (x is null) return y; if (y is null) return x; @@ -247,10 +272,17 @@ internal static uint ToLatencyTicks(TimeSpan latency) // then by latency uint xl = x._latencyTicks, yl = y._latencyTicks; - return xl <= yl ? x : y; + if (xl != yl) return xl < yl ? x : y; + + // getting hard to choose; is either of them the existing active node? choose that to prevent flapping + if (ReferenceEquals(x._muxer, active)) return x; + if (ReferenceEquals(y._muxer, active)) return y; + + // I've got nothing; choose x arbitrarily + return x; } - internal GroupConnectionChangedEventArgs.ChangeType UpdateState(HealthCheck.HealthCheckResult result) + internal GroupConnectionChangedEventArgs.ChangeType UpdateState(HealthCheck.HealthCheckResult result, long failbackFailureCutoffTicks) { bool isConnected; if (_muxer is { IsConnected: true } muxer) @@ -263,6 +295,15 @@ internal GroupConnectionChangedEventArgs.ChangeType UpdateState(HealthCheck.Heal isConnected = false; } + if (isConnected) + { + if (Volatile.Read(ref _lastUnhealthyTicks) < failbackFailureCutoffTicks) ResetIsUnhealthy(); + } + else + { + SetUnhealthy(); + } + var oldConnected = IsConnected; IsConnected = isConnected; @@ -321,14 +362,18 @@ static ConnectionMultiplexer Throw() => // a completed "no endpoint" result, shared by the database/subscriber facades when the group is fully down internal static readonly Task NoEndpoint = Task.FromResult(null); - private ConnectionGroupMember? GetActiveMember() + private ConnectionGroupMember? GetActiveMember() => GetMember(_active); + + private ConnectionGroupMember? GetMember(ConnectionMultiplexer? muxer) { - var active = _active; - foreach (var member in _members) + if (muxer is not null) { - if (ReferenceEquals(active, member.Multiplexer)) + foreach (var member in _members) { - return member; + if (ReferenceEquals(muxer, member.Multiplexer)) + { + return member; + } } } @@ -491,6 +536,7 @@ internal async Task RunHealthCheckAsync() } await Task.WhenAll(pending).TimeoutAfter(healthCheck.TotalTimeoutMillis()).ForAwait(); + var failbackFailureCutoff = GetFailbackFailureCutoff(); for (int i = 0; i < pending.Length; i++) { HealthCheck.HealthCheckResult result; @@ -504,7 +550,7 @@ internal async Task RunHealthCheckAsync() result = HealthCheck.HealthCheckResult.Unhealthy; } - var delta = members[i].UpdateState(result); + var delta = members[i].UpdateState(result, failbackFailureCutoff); if (delta != GroupConnectionChangedEventArgs.ChangeType.Unknown) { OnConnectionChanged(delta, members[i]); @@ -514,23 +560,37 @@ internal async Task RunHealthCheckAsync() HealthCheck.PutReusablePending(ref _reusableHealthCheckBuffer, ref pending); } + private long GetFailbackFailureCutoff() + { + // the minimum last-observed unhealthy time (as UTC ticks) that we'll allow for reconnect; + // for example, if the FailbackDelay is 2 minutes, and the time is 14:32:55, then the last + // failure must have happened at 14:30:55 or earlier. Pure long tick math on the wall clock: + // DateTime.Ticks and TimeSpan.Ticks are the same 100ns unit, so the subtraction is valid + var delay = _options.FailbackDelay; + if (delay == TimeSpan.MaxValue) return long.MinValue; // manual mode: never auto-reset + + return DateTime.UtcNow.Ticks - delay.Ticks; + } + internal void SelectPreferredGroup() { if (_disposed) return; - var previousMuxer = _active; + var existingActive = _active; ConnectionGroupMember? preferredMember = null, previousMember = null; var members = _members; foreach (var member in members) { - if (previousMember is null && ReferenceEquals(member.Multiplexer, previousMuxer)) + if (previousMember is null && ReferenceEquals(member.Multiplexer, existingActive)) { previousMember = member; } - if (member.IsConnected) + if (member.ConsiderActive) { member.UpdateLatency(); // this can change passively - preferredMember = ConnectionGroupMember.Select(preferredMember, member); + + // (note that when in doubt, we prefer the active muxer, to prevent flapping) + preferredMember = ConnectionGroupMember.Select(preferredMember, member, existingActive); } } @@ -710,6 +770,7 @@ private void OnMemberConnectionFailed(object? sender, ConnectionFailedEventArgs if (e.FailureType is ConnectionFailureType.CircuitBreaker && Interlocked.CompareExchange(ref _circuitBreakerDebounce, 1, 0) is 0) { + GetMember(sender as ConnectionMultiplexer)?.SetUnhealthy(); _ = Task.Run(async () => { try @@ -1157,7 +1218,7 @@ public async Task AddAsync(ConnectionGroupMember member, TextWriter? log = null) { var health = await (muxer.RawConfig.HealthCheck ?? _options.HealthCheck) .CheckHealthAsync(muxer).ConfigureAwait(false); - member.UpdateState(health); + member.UpdateState(health, GetFailbackFailureCutoff()); } // apply any shared hooks @@ -1214,6 +1275,7 @@ public bool TryFailoverTo(ConnectionGroupMember? member) return false; } + member.ResetIsUnhealthy(); // the user explicitly asked us to consider this node if (!member.IsConnected) { // not allowed diff --git a/src/StackExchange.Redis/Availability/MultiGroupOptions.cs b/src/StackExchange.Redis/Availability/MultiGroupOptions.cs index 3799836e69..779a1b6fd4 100644 --- a/src/StackExchange.Redis/Availability/MultiGroupOptions.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupOptions.cs @@ -45,6 +45,19 @@ public CircuitBreaker CircuitBreaker set => SetField(ref field, value); } + /// + /// If a member has been marked unhealthy by a failing health-check or circuit-breaker, it will not be + /// re-selected as the active member until it has remained healthy for this interval following its most + /// recent failure. When (the default), failback is immediate; when + /// , failback is not automatic and requires explicit use of + /// or . + /// + public TimeSpan FailbackDelay + { + get => field; + set => SetField(ref field, value); + } + // ReSharper disable once RedundantAssignment private void SetField(ref T field, T value, [CallerMemberName] string caller = "") { diff --git a/src/StackExchange.Redis/Interfaces/IConnectionGroup.cs b/src/StackExchange.Redis/Interfaces/IConnectionGroup.cs index 7f78328e31..e15f951afd 100644 --- a/src/StackExchange.Redis/Interfaces/IConnectionGroup.cs +++ b/src/StackExchange.Redis/Interfaces/IConnectionGroup.cs @@ -4,7 +4,6 @@ using System.Text; using System.Threading.Tasks; using RESPite; -using StackExchange.Redis.Availability; // ReSharper disable once CheckNamespace namespace StackExchange.Redis.Availability; @@ -34,6 +33,7 @@ public interface IConnectionGroup : IConnectionMultiplexer /// /// The member to switch to, or to remove an explicit failover. /// if the failover was successful (i.e. the member can be used), otherwise. + /// This will implicitly include . bool TryFailoverTo(ConnectionGroupMember? member); /// diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index b28ef249d7..9f2483eb93 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -121,3 +121,7 @@ StackExchange.Redis.IServer.InventKey(StackExchange.Redis.RedisKey prefix = defa [SER007]StackExchange.Redis.ConfigurationOptions.HealthCheck.get -> StackExchange.Redis.Availability.HealthCheck? [SER007]StackExchange.Redis.ConfigurationOptions.HealthCheck.set -> void [SER007]StackExchange.Redis.ConnectionFailureType.CircuitBreaker = 11 -> StackExchange.Redis.ConnectionFailureType +[SER007]StackExchange.Redis.Availability.ConnectionGroupMember.IsUnhealthy.get -> bool +[SER007]StackExchange.Redis.Availability.ConnectionGroupMember.ResetIsUnhealthy() -> void +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.FailbackDelay.get -> System.TimeSpan +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.FailbackDelay.set -> void From b438841c4e850f43554a1abed81c57b2bf0f4610 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 17 Jul 2026 15:20:55 +0100 Subject: [PATCH 17/33] Implement retries (#3132) * start dedup core * find classes * start stubbing class * successfully stub out all methods (it compiles!) * stub out execute and tuples * generalize mutator API to include channels * wip * fundamentals: compile (untested) * I *think* this is the core retry logic! * nit * fix build * iterating on RetryPolicy * nits * start command categorization * cleanup API * duplicate .Database into IDatabaseAsync (from IDatabase) * unit tests * end to end test, but it highlights a design fault * retry logic * nits * end-to-end tests * docs * namespace --- docs/ActiveActive.md | 370 +++++++++----- .../AutoDatabaseGenerator.cs | 483 ++++++++++++++++++ eng/StackExchange.Redis.Build/BasicArray.cs | 25 +- eng/StackExchange.Redis.Build/CodeWriter.cs | 18 +- src/RESPite/Messages/RespReader.cs | 23 + src/StackExchange.Redis/AutoDatabase.cs | 168 ++++++ .../Availability/CircuitBreaker.cs | 220 +++----- .../Availability/DatabaseExtensions.cs | 20 + .../Availability/FaultContext.cs | 100 ++++ .../Availability/MultiGroupDatabase.cs | 18 +- .../Availability/MultiGroupMultiplexer.cs | 56 +- .../MultiGroupSubscriber.Tracking.cs | 4 +- .../Availability/RetryDatabase.cs | 216 ++++++++ .../Availability/RetryPolicy.cs | 134 +++++ .../ConnectionMultiplexer.Sentinel.cs | 5 +- .../ConnectionMultiplexer.cs | 2 +- .../Enums/CommandFlags.Category.cs | 373 ++++++++++++++ src/StackExchange.Redis/Enums/CommandFlags.cs | 76 +++ src/StackExchange.Redis/ExceptionFactory.cs | 13 +- src/StackExchange.Redis/Exceptions.cs | 81 ++- .../Interfaces/IConnectionMultiplexer.cs | 1 + .../Interfaces/IDatabase.cs | 2 +- .../Interfaces/IDatabaseAsync.cs | 3 + .../Interfaces/IInternalDatabaseAsync.cs | 64 +++ .../KeyspaceIsolation/KeyPrefixed.cs | 19 +- .../KeyspaceIsolation/KeyPrefixedBatch.cs | 12 +- .../KeyspaceIsolation/KeyPrefixedDatabase.cs | 2 - .../KeyPrefixedTransaction.cs | 9 +- src/StackExchange.Redis/Message.cs | 24 +- src/StackExchange.Redis/PhysicalBridge.cs | 2 +- src/StackExchange.Redis/PhysicalConnection.cs | 6 +- .../PublicAPI/PublicAPI.Unshipped.txt | 122 ++++- src/StackExchange.Redis/RedisBatch.cs | 10 +- src/StackExchange.Redis/RedisDatabase.cs | 18 +- src/StackExchange.Redis/RedisErrorKind.cs | 235 +++++++++ src/StackExchange.Redis/RedisResult.cs | 51 +- src/StackExchange.Redis/RedisTransaction.cs | 8 +- .../ResultProcessor.Literals.cs | 10 - src/StackExchange.Redis/ResultProcessor.cs | 39 +- .../ServerSelectionStrategy.cs | 4 +- .../CircuitBreakerServerTests.cs | 12 +- .../CircuitBreakerUnitTests.cs | 60 +-- .../ControllableProbe.cs | 18 + .../CircuitBreakerRerouteTests.cs | 29 +- ...ts.cs => ReconnectRetryPolicyUnitTests.cs} | 2 +- .../RetryTests/CommandRetryPolicyUnitTests.cs | 191 +++++++ .../RetryTests/RetryEndToEndTests.cs | 153 ++++++ .../StackExchange.Redis.Server/RedisServer.cs | 10 + 48 files changed, 3022 insertions(+), 499 deletions(-) create mode 100644 eng/StackExchange.Redis.Build/AutoDatabaseGenerator.cs create mode 100644 src/StackExchange.Redis/AutoDatabase.cs create mode 100644 src/StackExchange.Redis/Availability/DatabaseExtensions.cs create mode 100644 src/StackExchange.Redis/Availability/FaultContext.cs create mode 100644 src/StackExchange.Redis/Availability/RetryDatabase.cs create mode 100644 src/StackExchange.Redis/Availability/RetryPolicy.cs create mode 100644 src/StackExchange.Redis/Enums/CommandFlags.Category.cs create mode 100644 src/StackExchange.Redis/Interfaces/IInternalDatabaseAsync.cs create mode 100644 src/StackExchange.Redis/RedisErrorKind.cs create mode 100644 tests/StackExchange.Redis.Tests/ControllableProbe.cs rename tests/StackExchange.Redis.Tests/{RetryPolicyUnitTests.cs => ReconnectRetryPolicyUnitTests.cs} (99%) create mode 100644 tests/StackExchange.Redis.Tests/RetryTests/CommandRetryPolicyUnitTests.cs create mode 100644 tests/StackExchange.Redis.Tests/RetryTests/RetryEndToEndTests.cs diff --git a/docs/ActiveActive.md b/docs/ActiveActive.md index be926a44fc..daa49f8263 100644 --- a/docs/ActiveActive.md +++ b/docs/ActiveActive.md @@ -2,8 +2,19 @@ ## Overview -The Active:Active feature provides automatic failover and intelligent routing across multiple Redis deployments. The library -automatically selects the best available endpoint based on: +The Active:Active feature provides automatic failover and intelligent routing across multiple Redis deployments. It is built from several cooperating pieces: + +1. **Connecting to multiple servers or regions at once**, giving a deployment redundant endpoints to fall back on. +2. **Health checks** that *actively* probe each endpoint on a timer to monitor its availability. +3. **Circuit breakers** that *passively* monitor availability from the observed success and failure of the traffic already flowing. +4. **Automatic retries** that let straightforward operations ride out a possibly-unstable connection — including silently transitioning to another endpoint during a failover, with no change to your calling code. + +The features for Active:Active are available in the `Availability` sub-namespace: + +``` csharp +using StackExchange.Redis.Availability; +``` +The library automatically selects the best available endpoint based on: 1. **Availability** - Connected endpoints are always preferred over disconnected ones 2. **Weight** - User-defined preference values (higher is better) @@ -354,93 +365,9 @@ var healthCheck = new HealthCheck ProbeCount = 3, ProbePolicy = HealthCheckProbePolicy.MajoritySuccess }; -// Healthy if 2 or more of probes succeed -``` - -### Custom Health Check Probes - -You can implement custom health check logic by extending `HealthCheckProbe`. Note that care must be used -if the probe involves talking to data via a `RedisKey`, as on "cluster" configurations, it must be ensured that the -key used resolves to the correct server; for this purpose, the `server.InventKey` method can be used: - -```csharp -public abstract class CustomProbe : HealthCheckProbe -{ - public override Task CheckHealthAsync(HealthCheck healthCheck, IServer server) - { - // create a random key that routes to the correct server, using - // the specified prefix - RedisKey key = server.InventKey("health-check/"); - // ... - } -} -```` - -Or more conveniently, the key-specific `KeyWriteHealthCheckProbe` encapsulates this logic: - -```csharp -public class CustomWriteProbe : KeyWriteHealthCheckProbe -{ - public override async Task CheckHealthAsync( - HealthCheck healthCheck, - IDatabaseAsync database, - RedisKey key) - { - try - { - var value = Guid.NewGuid().ToString(); - await database.StringSetAsync(key, value, expiry: healthCheck.ProbeTimeout); - bool isMatch = value == await database.StringGetAsync(key); - - return isMatch ? HealthCheckResult.Healthy : HealthCheckResult.Unhealthy; - } - catch - { - return HealthCheckResult.Unhealthy; - } - } -} -``` - -### Custom Probe Policies - -In addition to the inbuilt policies, custom policies can be implemented by extending `HealthCheckProbePolicy`. -By checking the properties of the `HealthCheckProbeContext` parameter, your policy can make a determination -about the health of the server - returning `HealthCheckResult.Healthy` or `HealthCheckResult.Unhealthy` as -appropriate. If you return `HealthCheckResult.Inconclusive`, the health check will continue with additional probes. - -#### Example: Require at Least N Successes - -This example demonstrates a policy that requires at least a specified number of successful probes before declaring the endpoint healthy: - -```csharp -public class AtLeastPolicy(int requiredSuccesses) : HealthCheckProbePolicy -{ - public override HealthCheckResult Evaluate(in HealthCheckProbeContext context) - { - // Success if we have at least the required number of successful probes - if (context.Success >= requiredSuccesses) return HealthCheckResult.Healthy; - - // If no more probes remaining, we haven't met our threshold; otherwise: keep trying - return context.Remaining == 0 ? HealthCheckResult.Unhealthy : HealthCheckResult.Inconclusive; - } -} - -// Use the custom policy requiring at least 2 successes -var healthCheck = new HealthCheck -{ - ProbeCount = 5, // Need enough probes to allow for the required successes - ProbePolicy = new AtLeastPolicy(2) -}; - -var options = new MultiGroupOptions -{ - HealthCheck = healthCheck -}; +// Healthy if 2 or more of 3 probes succeed ``` -This policy ensures that transient successes don't immediately mark an endpoint as healthy. It requires at least the specified number of successful probes, which provides better confidence in the endpoint's stability while still being more lenient than `AllSuccess`. - ### Health Check Behavior When a health check fails for a member: @@ -509,7 +436,6 @@ var options = new MultiGroupOptions FailureRateThreshold = 25, // trip above 25% failures MinimumNumberOfFailures = 100, // ...but only after 100 failures in the window MetricsWindowSize = TimeSpan.FromSeconds(5), // rolling window to measure over - TrackedExceptions = [typeof(RedisTimeoutException)], // which faults count as failures } }; ``` @@ -521,9 +447,8 @@ A `Builder` converts implicitly to a `CircuitBreaker`, so it can be assigned dir | `FailureRateThreshold` | 10 | Percentage of failures within the window that trips the breaker | | `MinimumNumberOfFailures` | 1000 | Minimum tracked failures in the window before the breaker can trip (avoids acting on tiny samples) | | `MetricsWindowSize` | 2 seconds | Rolling window over which successes and failures are counted | -| `TrackedExceptions` | `null` | Exception types that count as failures; when `null`, `RedisConnectionException` and `RedisTimeoutException` are tracked | -`TrackedExceptions` controls which faults count against the breaker; anything not tracked is treated as a success for circuit-breaking purposes (for example, a `RedisServerException` for a bad command is not a *connection* problem). Passing an array containing `typeof(Exception)` tracks everything; passing an empty array tracks nothing. +Which faults count against the breaker is decided by *classification*, not by exception type: transient and connection-level errors (and timeouts) count as failures, while application-level errors — for example a `RedisServerException` for a bad command, or a `WRONGTYPE` — are treated as a success for circuit-breaking purposes, since they are not a sign of an unhealthy *connection*. This is derived from the fault's `RedisErrorKind`; to change what counts, override `IsFailure(in FaultContext)` on a custom accumulator (see *Custom circuit breakers* below). ### Disabling the Circuit Breaker @@ -536,54 +461,87 @@ var options = new MultiGroupOptions }; ``` -### Custom Circuit Breakers (Advanced) +## Automatic Retries -> This is an advanced extension point; most applications should use `CircuitBreaker.Default` (optionally tuned via `CircuitBreaker.Builder`) or `CircuitBreaker.None`. - -You can implement your own policy by extending `CircuitBreaker` and its `Accumulator`. `CreateAccumulator()` is called once per underlying connection, so each accumulator holds the state for a single connection. -For every completed message the connection calls `ObserveResult`, passing a `CircuitBreakerContext` that exposes whether the operation succeeded (`Success`) and the associated `Fault` (if any). -Return `true` from `IsHealthy()` while the connection should be considered **healthy**, or `false` to **trip** the breaker and tear the connection down: +Health checks and circuit breakers keep the *group* pointed at a healthy member; **automatic retries** deal with the *individual operation* that was in flight when something went wrong. Wrapping a database with `WithRetry(...)` returns a database that transparently re-issues failed operations according to a `RetryPolicy` — riding out transient faults, and (in an Active:Active group) following a failover across to another member, without the caller having to catch-and-retry by hand. ```csharp -public sealed class ConsecutiveFailureBreaker(int limit) : CircuitBreaker -{ - public override Accumulator CreateAccumulator() => new Acc(limit); +await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members); - private sealed class Acc(int limit) : Accumulator - { - private int _consecutiveFailures; +// wrap the database once; reuse the wrapper like any other IDatabaseAsync +IDatabaseAsync db = conn.GetDatabase().WithRetry(new RetryPolicy()); - public override bool ObserveResult(in CircuitBreakerContext context) - { - if (context.Success) - { - _consecutiveFailures = 0; - } - else - { - _consecutiveFailures++; - } +// a transient fault (e.g. the active member briefly returning LOADING) is retried +// automatically; if the group fails over in the meantime, the retry lands on the new member +var value = await db.StringGetAsync("mykey"); +``` - // if Evaluate is specified, we should also compute IsHealthy(), - // often this can reuse local state needed by ObserveResult - return context.Evaluate ? IsHealthy() : true; - } +> You can call `WithRetry` on any database (`IDatabase` or `IDatabaseAsync`), but the wrapper it returns exposes only the **async** API — there is no synchronous form, since retrying may inherently have delays. It cannot wrap a batch or transaction, nor an already-retrying database. - // healthy until we hit the configured run of consecutive failures - public override bool IsHealthy() => _consecutiveFailures < limit; +### RetryPolicy settings - // called if the connection wants to discard accumulated history - public override void Reset() => _consecutiveFailures = 0; - } -} +`RetryPolicy` controls how many times, how often, and how far an operation is retried: -var options = new MultiGroupOptions +| Property | Default | Description | +|----------|---------|-------------| +| `MaxAttempts` | 3 | Total attempts (including the first) before giving up | +| `MaxAttemptsBeforeFailover` | 1 | Attempts against the current member before a retry is allowed to wait for / move to a failover member (only meaningful for multi-group connections) | +| `RetryDelay` | 1 second | Delay between same-server retries | +| `JitterMax` | 0.5 seconds | Upper bound of the additional random delay added to each retry, to avoid stampedes | +| `FailoverDelay` | 5 seconds | Maximum time to wait for a failover, when a retry is gated on one happening | +| `MaxCommandRetryCategory` | `CommandRetryWriteLastWins` | The most side-effecting command category that will be retried (see below) | + +```csharp +var policy = new RetryPolicy { - CircuitBreaker = new ConsecutiveFailureBreaker(limit: 5) + MaxAttempts = 5, + RetryDelay = TimeSpan.FromMilliseconds(200), + JitterMax = TimeSpan.FromMilliseconds(100), }; +IDatabaseAsync db = conn.GetDatabase().WithRetry(policy); ``` -Keep `ObserveResult` cheap and thread-safe: it runs on the hot path for every completed message, and may be called concurrently. +Only *transient* faults are retried — the same `RedisErrorKind`-based classification the default circuit breaker uses. An application-level error such as `WRONGTYPE`, or an unknown command, is not transient and is never retried, no matter what the policy says. + +### Which operations are safe to retry + +Retrying is not free of consequence: replaying `INCR` after an ambiguous failure could double-count, whereas replaying `GET` is harmless; `SET` is "last wins", so: *usally* fine. Every command therefore carries a **retry category** describing its side-effects, and a policy only retries commands at or below its `MaxCommandRetryCategory`. + +For the built-in typed methods (`StringGet`, `StringSet`, `HashSet`, ...) the library assigns the appropriate category automatically, so retries "just work" within the default policy. + +### Custom commands: `Execute` and `ScriptEvaluate` + +The library cannot infer the side-effects of a command it doesn't recognise — and that includes arbitrary commands issued via `Execute`/`ExecuteAsync`, and Lua run via `ScriptEvaluate`/`ScriptEvaluateAsync` (whose effect depends entirely on the script). Such commands are therefore treated **pessimistically**: an uncategorised command defaults to `CommandRetryNever` and is *not* retried. + +The categories, from safest to most dangerous, are: + +| `CommandFlags` value | Meaning | +|----------------------|---------| +| `CommandRetryAlways` | Always safe to retry, regardless of connection/server state | +| `CommandRetryConnection` | Connection-level or safe metadata (e.g. `CLIENT SETNAME`, `CONFIG GET`) | +| `CommandRetryReadOnly` | Pure reads (e.g. `GET`) | +| `CommandRetryWriteChecked` | Conditional writes (e.g. `SETNX`, `SET ... IFEQ`) | +| `CommandRetryWriteLastWins` | Unconditional overwrite — last-writer-wins (e.g. `SET`) | +| `CommandRetryWriteAccumulating` | Cumulative writes where a retry can double-apply (e.g. `INCR`, `LPUSH`) | +| `CommandRetryServerAdmin` | Server administration (e.g. `CONFIG SET`) | +| `CommandRetryNever` | Never retry | + + +When possible when using ad-hoc commands or script, callers should supply the most appropriate `CommandRetry*` category in the command's `CommandFlags`: + +```csharp +// an arbitrary read-only command: safe to retry +var result = await db.ExecuteAsync("LOLWUT", args: [], flags: CommandFlags.CommandRetryReadOnly); + +// a Lua script that only reads: opt into retries +var value = await db.ScriptEvaluateAsync( + "return redis.call('GET', KEYS[1])", + keys: [key], + flags: CommandFlags.CommandRetryReadOnly); +``` + +Choose the category honestly — it describes what a *replay* would do. If a retry could double-apply a side-effect, use `CommandRetryWriteAccumulating` (or leave it uncategorised) rather than claiming it's a read. +Conversely, if you want more-side-effecting operations retried across the board, raise the policy's `MaxCommandRetryCategory` instead of tagging each call. ## Unhealthy State and Failback @@ -825,3 +783,165 @@ if (newDC.IsConnected) } } ``` + +## Advanced Customization + +The building blocks above cover the common cases. The extension points below let you replace the default health-check, retry, and circuit-breaker behavior with your own logic; most applications will not need them. + +### Custom Health Check Probes + +You can implement custom health check logic by extending `HealthCheckProbe`. Note that care must be used +if the probe involves talking to data via a `RedisKey`, as on "cluster" configurations, it must be ensured that the +key used resolves to the correct server; for this purpose, the `server.InventKey` method can be used: + +```csharp +public abstract class CustomProbe : HealthCheckProbe +{ + public override Task CheckHealthAsync(HealthCheck healthCheck, IServer server) + { + // create a random key that routes to the correct server, using + // the specified prefix + RedisKey key = server.InventKey("health-check/"); + // ... + } +} +``` + +Or more conveniently, the key-specific `KeyWriteHealthCheckProbe` encapsulates this logic: + +```csharp +public class CustomWriteProbe : KeyWriteHealthCheckProbe +{ + public override async Task CheckHealthAsync( + HealthCheck healthCheck, + IDatabaseAsync database, + RedisKey key) + { + try + { + var value = Guid.NewGuid().ToString(); + await database.StringSetAsync(key, value, expiry: healthCheck.ProbeTimeout); + bool isMatch = value == await database.StringGetAsync(key); + + return isMatch ? HealthCheckResult.Healthy : HealthCheckResult.Unhealthy; + } + catch + { + return HealthCheckResult.Unhealthy; + } + } +} +``` + +### Custom Probe Policies + +In addition to the inbuilt policies, custom policies can be implemented by extending `HealthCheckProbePolicy`. +By checking the properties of the `HealthCheckProbeContext` parameter, your policy can make a determination +about the health of the server - returning `HealthCheckResult.Healthy` or `HealthCheckResult.Unhealthy` as +appropriate. If you return `HealthCheckResult.Inconclusive`, the health check will continue with additional probes. + +#### Example: Require at Least N Successes + +This example demonstrates a policy that requires at least a specified number of successful probes before declaring the endpoint healthy: + +```csharp +public class AtLeastPolicy(int requiredSuccesses) : HealthCheckProbePolicy +{ + public override HealthCheckResult Evaluate(in HealthCheckProbeContext context) + { + // Success if we have at least the required number of successful probes + if (context.Success >= requiredSuccesses) return HealthCheckResult.Healthy; + + // If no more probes remaining, we haven't met our threshold; otherwise: keep trying + return context.Remaining == 0 ? HealthCheckResult.Unhealthy : HealthCheckResult.Inconclusive; + } +} + +// Use the custom policy requiring at least 2 successes +var healthCheck = new HealthCheck +{ + ProbeCount = 5, // Need enough probes to allow for the required successes + ProbePolicy = new AtLeastPolicy(2) +}; + +var options = new MultiGroupOptions +{ + HealthCheck = healthCheck +}; +``` + +This policy ensures that transient successes don't immediately mark an endpoint as healthy. It requires at least the specified number of successful probes, which provides better confidence in the endpoint's stability while still being more lenient than `AllSuccess`. + +### Custom Circuit Breakers + +> This is an advanced extension point; most applications should use `CircuitBreaker.Default` (optionally tuned via `CircuitBreaker.Builder`) or `CircuitBreaker.None`. + +You can implement your own policy by extending `CircuitBreaker` and its `Accumulator`. `CreateAccumulator()` is called once per underlying connection, so each accumulator holds the state for a single connection. +For every completed message the connection calls `ObserveResult`, passing a `FaultContext` that describes the outcome: `IsFault` indicates whether a fault occurred, with the associated `Fault`, `ErrorKind` and `ConnectionFailureType` available for inspection. `IsHealthy()` is consulted separately: return `true` while the connection should be considered **healthy**, or `false` to **trip** the breaker and tear the connection down. + +By default only faults that `IsFailure` regards as genuine failures reach `ObserveResult` *as* failures (transient/connection errors, timeouts, and similar - classified from the fault's `ErrorKind`); everything else - including application-level errors such as a bad command - is passed as a success. Override `IsFailure(in FaultContext)` if you need different rules for what counts: + +```csharp +public sealed class ConsecutiveFailureBreaker(int limit) : CircuitBreaker +{ + public override Accumulator CreateAccumulator() => new Acc(limit); + + private sealed class Acc(int limit) : Accumulator + { + private int _consecutiveFailures; + + // a fault is present iff context.IsFault; a success resets the run + public override void ObserveResult(in FaultContext context) + { + if (context.IsFault) + { + _consecutiveFailures++; + } + else + { + _consecutiveFailures = 0; + } + } + + // healthy until we hit the configured run of consecutive failures + public override bool IsHealthy() => _consecutiveFailures < limit; + + // called if the connection wants to discard accumulated history + public override void Reset() => _consecutiveFailures = 0; + + // (optional) override to change what counts as a failure; the default classifies from ErrorKind + // protected override bool IsFailure(in FaultContext fault) => fault.IsFault; + } +} + +var options = new MultiGroupOptions +{ + CircuitBreaker = new ConsecutiveFailureBreaker(limit: 5) +}; +``` + +Keep `ObserveResult` cheap and thread-safe: it runs on the hot path for every completed message, and may be called concurrently. + +### Custom Retry Policies + +`RetryPolicy` is itself extensible: override `CanRetry(in FaultContext fault)` to make the retry decision yourself. It returns a `RetryPolicyResult` — `None` to give up, or a combination of `SameServer` and `FailoverServer` to indicate where a retry may be attempted. +The `FaultContext` gives you the classified `ErrorKind`, the `ConnectionFailureType`, and the command `Flags` (including its retry category) to base the decision on: + +```csharp +public sealed class ReadOnlyOnlyRetryPolicy : RetryPolicy +{ + public override RetryPolicyResult CanRetry(in FaultContext fault) + { + // only ever retry pure reads, and only on the same server + if (fault.ErrorKind == RedisErrorKind.Loading + && (fault.Flags & CommandFlags.CommandRetryReadOnly) != 0) + { + return RetryPolicyResult.SameServer; + } + // note: base.CanRetry(fault) would apply the default logic + return RetryPolicyResult.None; + } +} + +IDatabaseAsync db = conn.GetDatabase().WithRetry(new ReadOnlyOnlyRetryPolicy()); +``` diff --git a/eng/StackExchange.Redis.Build/AutoDatabaseGenerator.cs b/eng/StackExchange.Redis.Build/AutoDatabaseGenerator.cs new file mode 100644 index 0000000000..356e726194 --- /dev/null +++ b/eng/StackExchange.Redis.Build/AutoDatabaseGenerator.cs @@ -0,0 +1,483 @@ +using System.Collections.Immutable; +using System.Reflection.Metadata.Ecma335; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +// readable names for the info we gather; kept as tuples (+ BasicArray) so they have +// value equality and play nicely with incremental generator caching. +using ParamInfo = (string Name, string Type, Microsoft.CodeAnalysis.RefKind RefKind, bool IsParams, bool IsOptional, bool HasDefault, string? Default); +using MethodInfo = (string Name, string ReturnType, StackExchange.Redis.Build.BasicArray<(string Name, string Type, Microsoft.CodeAnalysis.RefKind RefKind, bool IsParams, bool IsOptional, bool HasDefault, string? Default)> Parameters, StackExchange.Redis.Build.BasicArray TypeArgs); +using InterfaceInfo = (string Name, string Namespace, StackExchange.Redis.Build.AutoDatabaseGenerator.KnownInterfaces KnownType, StackExchange.Redis.Build.BasicArray<(string Name, string ReturnType, StackExchange.Redis.Build.BasicArray<(string Name, string Type, Microsoft.CodeAnalysis.RefKind RefKind, bool IsParams, bool IsOptional, bool HasDefault, string? Default)> Parameters, StackExchange.Redis.Build.BasicArray TypeArgs)> Methods); +using ClassInfo = (string Name, string Namespace, StackExchange.Redis.Build.AutoDatabaseGenerator.KnownInterfaces Interfaces); + +namespace StackExchange.Redis.Build; + +[Generator(LanguageNames.CSharp)] +public class AutoDatabaseGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext ctx) + { + var interfaces = ctx.SyntaxProvider + .CreateSyntaxProvider( + static (node, _) => node is InterfaceDeclarationSyntax decl && FastIndexFilter(decl), ExtractInterfaceMethods) + .Where(pair => pair.Name is { Length: > 0 }) + .Collect(); + + var classes = ctx.SyntaxProvider + .CreateSyntaxProvider( + static (node, _) => node is ClassDeclarationSyntax decl && FastClassFilter(decl), ExtractClasses) + .Where(pair => pair.Name is { Length: > 0 }) + .Collect(); + + ctx.RegisterSourceOutput(interfaces.Combine(classes), static (ctx, content) => Generate(ctx, content.Left, content.Right)); + } + + static KnownInterfaces Identify(string type) => type switch + { + "IDatabase" => KnownInterfaces.IDatabase, + "IDatabaseAsync" => KnownInterfaces.IDatabaseAsync, + "IRedis" => KnownInterfaces.IRedis, + "IRedisAsync" => KnownInterfaces.IRedisAsync, + _ => KnownInterfaces.None, + }; + + private static bool FastIndexFilter(InterfaceDeclarationSyntax decl) // limit to IDatabase, IDatabaseAsync + => Identify(decl.Identifier.ValueText) is not 0; + + private static bool FastClassFilter(ClassDeclarationSyntax decl) // limit to IDatabase, IDatabaseAsync + => decl.AttributeLists.Any(x => x.Attributes.Any(x => x.Name.ToString() is "AutoDatabase" or "AutoDatabaseAttribute")); + + private static bool IsOurInterface(INamedTypeSymbol symbol) => + symbol is + { + TypeKind: TypeKind.Interface, + Name: "IDatabase" or "IDatabaseAsync" or "IRedis" or "IRedisAsync", + ContainingType: null, + ContainingNamespace: + { + Name: "Redis", + ContainingNamespace: + { + Name: "StackExchange", + ContainingNamespace.IsGlobalNamespace: true + } + } + }; + + private static InterfaceInfo ExtractInterfaceMethods(GeneratorSyntaxContext context, CancellationToken cancel) + { + // note: we deliberately do NOT interpret anything here - just capture the raw shape of every + // method (name, return type, and per-parameter name/type/modifiers/optionality/default) so that + // later passes have everything they might need. + if (context.SemanticModel.GetDeclaredSymbol(context.Node, cancel) is not INamedTypeSymbol iface + || !IsOurInterface(iface)) + { + return default; + } + + var knownType = Identify(iface.Name); + if (knownType is KnownInterfaces.None) return default; + + var methods = new List(); + foreach (var member in iface.GetMembers()) + { + cancel.ThrowIfCancellationRequested(); + if (member is not IMethodSymbol { MethodKind: MethodKind.Ordinary } method) continue; + + var parameters = BasicArray.From(method.Parameters, static p => ( + Name: p.Name, + Type: p.Type.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat), + RefKind: p.RefKind, + IsParams: p.IsParams, + IsOptional: p.IsOptional, + HasDefault: p.HasExplicitDefaultValue, + Default: p.HasExplicitDefaultValue ? FormatDefault(p.ExplicitDefaultValue) : null)); + + BasicArray typeArgs = default; + if (method.IsGenericMethod) + { + typeArgs = BasicArray.From(method.TypeParameters, p => p.Name); + } + methods.Add(( + Name: method.Name, + ReturnType: method.ReturnType.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat), + Parameters: parameters, + TypeArgs: typeArgs)); + } + + var ns = iface.ContainingNamespace.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + return (iface.Name, ns, knownType, BasicArray.From(methods)); + } + + private ClassInfo ExtractClasses(GeneratorSyntaxContext context, CancellationToken cancel) + { + // note: we deliberately do NOT interpret anything here - just capture the raw shape of every + // method (name, return type, and per-parameter name/type/modifiers/optionality/default) so that + // later passes have everything they might need. + if (context.SemanticModel.GetDeclaredSymbol(context.Node, cancel) is not INamedTypeSymbol cls + || !HasAutoDatabaseAttrib(cls)) + { + return default; + } + + static bool HasAutoDatabaseAttrib(INamedTypeSymbol symbol) + { + var attribs = symbol.GetAttributes(); + foreach (var attrib in attribs) + { + if (attrib.AttributeClass is + { + Name: "AutoDatabaseAttribute", + ContainingType: null, + ContainingNamespace: + { + Name: "Redis", + ContainingNamespace: + { + Name: "StackExchange", + ContainingNamespace.IsGlobalNamespace: true + } + } + }) + { + return true; + } + } + + return false; + } + + KnownInterfaces known = 0; + foreach (var iFace in cls.Interfaces) + { + if (IsOurInterface(iFace)) + { + switch (iFace.Name) + { + case "IDatabase": + known |= KnownInterfaces.IDatabase | KnownInterfaces.IDatabaseAsync | + KnownInterfaces.IRedis | KnownInterfaces.IRedisAsync; + break; + case "IDatabaseAsync": + known |= KnownInterfaces.IDatabaseAsync | KnownInterfaces.IRedisAsync; + break; + case "IRedis": + known |= KnownInterfaces.IRedis | KnownInterfaces.IRedisAsync; + break; + case "IRedisAsync": + known |= KnownInterfaces.IRedisAsync; + break; + } + } + } + + if (known is KnownInterfaces.None) return default; // nothing to do! + + var ns = cls.ContainingNamespace.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + return (cls.Name, ns, known); + } + + [Flags] + internal enum KnownInterfaces + { + None = 0, + IDatabase = 1, + IDatabaseAsync = 2, + IRedis = 4, + IRedisAsync = 8, + } + + // methods that don't fit the capture-and-replay shape are left for the caller to implement manually: + // - generic methods (open type args can't be captured into a concrete state struct) + // - the Wait family (synchronization over caller-supplied Tasks, not server calls) + // - IsConnected: a synchronous status probe that IDatabaseAsync carries; it would route through the + // sync Execute funnel, which an async-only database (e.g. RetryDatabase) doesn't provide - and a + // connection check shouldn't be retried in any case + // - streaming returns (IEnumerable / IAsyncEnumerable) whose execution is deferred + private static bool SkipMethod(MethodInfo method) + => !method.TypeArgs.IsEmpty + || method.Name.Contains("Wait") + || method.Name == "IsConnected" + || method.ReturnType.StartsWith("System.Collections.Generic.IEnumerable<", StringComparison.Ordinal) + || method.ReturnType.StartsWith("System.Collections.Generic.IAsyncEnumerable<", StringComparison.Ordinal); + + private const string TaskType = "System.Threading.Tasks.Task"; + + // Task / Task returns route through ExecuteAsync (its own retry policy); everything else + // uses Execute. + private static bool IsAsync(string returnType) + => returnType.StartsWith(TaskType, StringComparison.Ordinal); + + // the retry machinery unwraps the Task and only ever sees the inner result, so key/channel + // (un)mapping must be decided on T, not Task; this strips the Task<...> wrapper from an async + // return type. A bare (non-generic) Task has no result and is returned unchanged (as is any + // non-async return type), which is harmless since neither matches NeedsMap. + private static string StripTask(string returnType) + => IsAsync(returnType) && returnType.StartsWith(TaskType + "<", StringComparison.Ordinal) + ? returnType.Substring(TaskType.Length + 1, returnType.Length - TaskType.Length - 2) + : returnType; + + private static string FormatDefault(object? value) => value switch + { + null => "null", + string s => $"\"{s}\"", + bool b => b ? "true" : "false", + _ => value.ToString() ?? "null", + }; + + private static void Generate(SourceProductionContext ctx, ImmutableArray interfaces, ImmutableArray classes) + { + if (interfaces.IsDefaultOrEmpty | classes.IsDefaultOrEmpty) return; // nothing to do + + // each interface is declared across several partial files, so we get one (identical) entry per + // partial declaration; structural equality lets us collapse those down to one per interface. + var iKeyed = new Dictionary(); + foreach (var t in interfaces) + { + if (t.KnownType is KnownInterfaces.None) continue; + if (!iKeyed.ContainsKey(t.KnownType)) iKeyed.Add(t.KnownType, t); + } + + var sb = new StringBuilder(); + var writer = new CodeWriter(sb); + writer.NewLine().Append("// "); + writer.NewLine().Append("// AutoDatabaseGenerator - explicit interface implementations that funnel every"); + writer.NewLine().Append("// call through Execute(state, projection), capturing the arguments in a generated"); + writer.NewLine().Append("// state struct (one per unique parameter-type signature) to avoid per-call closures."); + writer.NewLine().Append("#nullable enable"); // this needs to be explicit for code-gen + writer.NewLine(); + foreach (var cls in classes.Distinct()) + { + if (!string.IsNullOrWhiteSpace(cls.Namespace)) + { + writer.NewLine().Append("namespace ").Append(cls.Namespace).NewLine().Append("{").Indent(); + } + + // unique parameter-type signatures encountered while emitting this class's methods; + // keyed on the '|'-joined parameter types so distinct methods with the same shape share + // one state struct. tupleDefs[i] holds a representative parameter list for _tuple{i}. + var tupleIndex = new Dictionary(); + var tupleDefs = new List>(); + + // every unique key/channel-bearing result type across all shapes; a single shared + // singleton (emitted after the tuples) implements IRedisArgsResult for each, so + // tuples expose unmap dispatch via UnMapper without ever being cast to an interface + var allReturns = new HashSet(); + + bool isFirst = true; + writer.NewLine().Append("partial class ").Append(cls.Name); + AppendInterfaceDeclaration(KnownInterfaces.IDatabase); + AppendInterfaceDeclaration(KnownInterfaces.IDatabaseAsync); + AppendInterfaceDeclaration(KnownInterfaces.IRedis); + AppendInterfaceDeclaration(KnownInterfaces.IRedisAsync); + writer.NewLine().Append("{").Indent(); + AppendInterfaceMethods(KnownInterfaces.IDatabase); + AppendInterfaceMethods(KnownInterfaces.IDatabaseAsync); + AppendInterfaceMethods(KnownInterfaces.IRedis); + AppendInterfaceMethods(KnownInterfaces.IRedisAsync); + AppendTupleTypes(); + + writer.Outdent().NewLine().Append("}"); + + if (!string.IsNullOrWhiteSpace(cls.Namespace)) + { + writer.Outdent().NewLine().Append("}"); + } + writer.NewLine(); + + + void AppendInterfaceDeclaration(KnownInterfaces knownType) + { + if ((cls.Interfaces & knownType) is 0 | !iKeyed.TryGetValue(knownType, out var iType)) return; + writer.Append(isFirst ? " : " : ", ").Append("global::") + .Append(iType.Namespace).Append('.') .Append(iType.Name); + isFirst = false; + } + + void AppendInterfaceMethods(KnownInterfaces knownType) + { + if ((cls.Interfaces & knownType) is 0 | !iKeyed.TryGetValue(knownType, out var iType)) return; + foreach (var method in iType.Methods) + { + if (SkipMethod(method)) continue; // wonky by nature - left for the caller to implement manually + + writer.NewLine().Append(method.ReturnType).Append(" global::") + .Append(iType.Namespace).Append('.').Append(iType.Name).Append('.').Append(method.Name).Append("("); + bool firstParam = true; + foreach (var p in method.Parameters.Span) + { + if (firstParam) firstParam = false; + else writer.Append(", "); + writer.Append(p.Type).Append(" ").Append(p.Name); + } + writer.Append(')').Indent().NewLine(); + + // async methods route to ExecuteAsync (its own retry policy); everything else uses + // Execute. The state struct is shared regardless of return type - keyed on parameter + // types only - so e.g. ArraySet and ArraySetAsync land on the same _tupleN. The return + // type is Task-stripped so unmapping is keyed on the inner result the retry machinery + // actually sees, not the Task wrapper. + bool isAsync = IsAsync(method.ReturnType); + var simpleResult = StripTask(method.ReturnType); + bool needsMap = NeedsMap(simpleResult); + if (needsMap) allReturns.Add(simpleResult); + int tuple = GetTupleIndex(method.Parameters, needsMap); + writer.Append(isAsync ? "=> ExecuteAsync(new _tuple" : "=> Execute(new _tuple").Append(tuple).Append("("); + firstParam = true; + foreach (var p in method.Parameters.Span) + { + if (firstParam) firstParam = false; + else writer.Append(", "); + writer.Append(p.Name); + } + writer.Append("), static (state, inner) => inner.").Append(method.Name).Append("("); + for (int i = 0; i < method.Parameters.Length; i++) + { + if (i != 0) writer.Append(", "); + writer.Append("state.Arg").Append(i); + } + writer.Append("));").Outdent().NewLine(); + } + } + + static string GetTupleKey(BasicArray parameters) + { + var keyBuilder = new StringBuilder(); + foreach (var p in parameters.Span) + { + keyBuilder.Append(p.Type).Append('|'); // types-only key; no ref/out on this surface, so type is sufficient + } + + return keyBuilder.ToString(); + } + + bool TupleNeedsMap(BasicArray parameters) => + tupleIndex.TryGetValue(GetTupleKey(parameters), out var found) && found.NeedsMap; + + int GetTupleIndex(BasicArray parameters, bool needsMap) + { + var key = GetTupleKey(parameters); + int index; + if (tupleIndex.TryGetValue(key, out var found)) + { + index = found.Index; + if (needsMap && !found.NeedsMap) + { + found.NeedsMap = true; + tupleIndex[key] = found; + } + } + else + { + index = tupleDefs.Count; + tupleIndex.Add(key, (index, needsMap)); + tupleDefs.Add(parameters); + } + + return index; + } + + // const string CommandFlagsType = "StackExchange.Redis.CommandFlags"; + const string RedisKeyType = "StackExchange.Redis.RedisKey"; + const string RedisChannelType = "StackExchange.Redis.RedisChannel"; + // types that carry key(s) internally without "RedisKey" in their name, so the + // substring check below won't catch them; they rely on a Map extension method + const string StreamPositionType = "StackExchange.Redis.StreamPosition"; + // the Execute/ExecuteAsync escape hatch boxes keys/channels inside a loosely-typed + // arg list; these route through a Map extension that unboxes and rewrites matches + const string ScriptArgArrayType = "object[]"; + const string ScriptArgCollectionType = "System.Collections.Generic.ICollection"; + + static bool NeedsMap(string name) => + name.IndexOf(RedisKeyType, StringComparison.Ordinal) >= 0 + || name.IndexOf(RedisChannelType, StringComparison.Ordinal) >= 0 + || name.IndexOf(StreamPositionType, StringComparison.Ordinal) >= 0 + || name == ScriptArgArrayType + || name == ScriptArgCollectionType + || name.IndexOf("ListPopResult", StringComparison.Ordinal) >= 0 + || name.IndexOf("SortedSetPopResult", StringComparison.Ordinal) >= 0 + || name == ScriptArgCollectionType + "?"; + + void AppendTupleTypes() + { + for (int i = 0; i < tupleDefs.Count; i++) + { + var raw = tupleDefs[i]; + bool needsMap = TupleNeedsMap(raw); + var parameters = raw.Span; + + // fields are mutable: Map rewrites key/channel fields and Flags has a setter + writer.NewLine().NewLine().Append("private struct _tuple").Append(i).Append("("); + for (int p = 0; p < parameters.Length; p++) + { + if (p != 0) writer.Append(", "); + writer.Append(parameters[p].Type).Append(" arg").Append(p); + } + + writer.Append(") : global::StackExchange.Redis.IRedisArgs"); + writer.NewLine().Append("{").Indent(); + for (int p = 0; p < parameters.Length; p++) + { + writer.NewLine().Append("public ").Append(NeedsMap(parameters[p].Type) ? "" : "readonly ").Append(parameters[p].Type) + .Append(" Arg").Append(p).Append(" = arg").Append(p).Append(";"); + } + + // Map rewrites each scalar key/channel field directly, and defers container or + // loosely-typed fields to a matching Map extension method + writer.NewLine().Append("public void Map(global::StackExchange.Redis.IRedisArgsMutator mutator)") + .NewLine().Append("{").Indent(); + for (int p = 0; p < parameters.Length; p++) + { + if (NeedsMap(parameters[p].Type)) + { + // key/channel-bearing container (or the loosely-typed script arg list); it + // is the library's job to ensure a suitable Map extension method exists + writer.NewLine().Append("Arg").Append(p).Append(" = mutator.Map(Arg").Append(p).Append(");"); + } + } + writer.Outdent().NewLine().Append("}"); + + // tuples with key/channel-bearing results point UnMapper at the shared singleton + // (which knows how to unmap every such result type); the rest return null so the + // Execute helper skips unmapping entirely - and neither path boxes the struct + writer.NewLine().Append("public readonly object? UnMapper => ") + .Append(needsMap ? "_UnMapper.Instance;" : "null;"); + + writer.Outdent().NewLine().Append("}"); + } + + if (allReturns.Count is not 0) + { + // sorted for deterministic (cache-stable) output + var ordered = new List(allReturns); + ordered.Sort(StringComparer.Ordinal); + + writer.NewLine().NewLine().Append("private sealed class _UnMapper").Indent(); + bool firstIface = true; + foreach (var retType in ordered) + { + writer.NewLine().Append(firstIface ? ": " : ", ") + .Append("global::StackExchange.Redis.IRedisArgsResult<").Append(retType).Append(">"); + firstIface = false; + } + writer.Outdent().NewLine().Append("{").Indent(); + writer.NewLine().Append("public static readonly _UnMapper Instance = new();"); + foreach (var retType in ordered) + { + writer.NewLine().NewLine().Append(retType).Append(' ') + .Append("global::StackExchange.Redis.IRedisArgsResult<") + .Append(retType) + .Append(">.UnMap(global::StackExchange.Redis.IRedisArgsMutator mutator, ") + .Append(retType).Append(" value)") + .Indent().NewLine().Append("=> mutator.UnMap(value);").Outdent(); + } + writer.Outdent().NewLine().Append("}"); + } + } + } + writer.NewLine(); + + ctx.AddSource("AutoDatabase.generated.cs", sb.ToString()); + } +} diff --git a/eng/StackExchange.Redis.Build/BasicArray.cs b/eng/StackExchange.Redis.Build/BasicArray.cs index dc7984c75b..0c2df6f925 100644 --- a/eng/StackExchange.Redis.Build/BasicArray.cs +++ b/eng/StackExchange.Redis.Build/BasicArray.cs @@ -38,7 +38,7 @@ public bool Equals(BasicArray other) int i = 0; foreach (ref readonly T el in this.Span) { - if (!_comparer.Equals(el, y[i])) return false; + if (!_comparer.Equals(el, y[i++])) return false; } return true; @@ -58,7 +58,7 @@ public override int GetHashCode() var hash = Length; foreach (ref readonly T el in this.Span) { - _ = (hash * -37) + _comparer.GetHashCode(el); + hash = (hash * -37) + _comparer.GetHashCode(el); } return hash; @@ -82,4 +82,25 @@ public void Add(in T value) public BasicArray Build() => new(elements, Count); } + + public static BasicArray From(ICollection collection) + { + if (collection.Count is 0) return default; + var arr = new T[collection.Count]; + collection.CopyTo(arr, 0); + return new(arr, arr.Length); + } + + public static BasicArray From(ICollection collection, Func selector) + { + if (collection.Count is 0) return default; + var arr = new T[collection.Count]; + int i = 0; + foreach (var item in collection) + { + arr[i++] = selector(item); + } + + return new(arr, i); + } } diff --git a/eng/StackExchange.Redis.Build/CodeWriter.cs b/eng/StackExchange.Redis.Build/CodeWriter.cs index 94d30ef471..8d30913225 100644 --- a/eng/StackExchange.Redis.Build/CodeWriter.cs +++ b/eng/StackExchange.Redis.Build/CodeWriter.cs @@ -14,7 +14,8 @@ internal sealed class CodeWriter(StringBuilder buffer) /// Starts a new line, applying the current indent. public CodeWriter NewLine() { - buffer.AppendLine().Append(' ', _indent * 4); + buffer.AppendLine(); + _lineHasContent = false; return this; } @@ -32,26 +33,41 @@ public CodeWriter Outdent() return this; } + private bool _lineHasContent; + + private void IndentIfNeeded() + { + if (!_lineHasContent) + { + buffer.Append(' ', _indent * 4); + _lineHasContent = true; + } + } + public CodeWriter Append(string? value) { + IndentIfNeeded(); buffer.Append(value); return this; } public CodeWriter Append(char value) { + IndentIfNeeded(); buffer.Append(value); return this; } public CodeWriter Append(int value) { + IndentIfNeeded(); buffer.Append(value); return this; } public CodeWriter Append(long value) { + IndentIfNeeded(); buffer.Append(value); return this; } diff --git a/src/RESPite/Messages/RespReader.cs b/src/RESPite/Messages/RespReader.cs index 413d0174da..2137946d8a 100644 --- a/src/RESPite/Messages/RespReader.cs +++ b/src/RESPite/Messages/RespReader.cs @@ -722,6 +722,29 @@ public readonly unsafe bool TryParseScalar( return TryGetSpan(out var span) ? parser(span, out value) : TryParseSlow(parser, out value); } + // same thing as ^^^, but as a utility method for text values, paying UTF8 encode + internal static unsafe bool TryParseScalar( + ReadOnlySpan source, + delegate* managed, out T, bool> parser, + out T value) + { + const int MAX_STACK = 128; + byte[]? lease = null; + int maxLen = RespConstants.UTF8.GetMaxByteCount(source.Length); + Span buffer = maxLen <= MAX_STACK + ? stackalloc byte[MAX_STACK] // prefer fixed size for perf + : (lease = ArrayPool.Shared.Rent(maxLen)); + try + { + var len = RespConstants.UTF8.GetBytes(source, buffer); + return parser(buffer.Slice(0, len), out value); + } + finally + { + if (lease is not null) ArrayPool.Shared.Return(lease); + } + } + [MethodImpl(MethodImplOptions.NoInlining)] private readonly unsafe bool TryParseSlow( delegate* managed, out T, bool> parser, diff --git a/src/StackExchange.Redis/AutoDatabase.cs b/src/StackExchange.Redis/AutoDatabase.cs new file mode 100644 index 0000000000..395c5f70c5 --- /dev/null +++ b/src/StackExchange.Redis/AutoDatabase.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace StackExchange.Redis; + +[Conditional("DEBUG")] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = false)] +internal sealed class AutoDatabaseAttribute : Attribute +{ +} + +internal interface IRedisArgs +{ + void Map(IRedisArgsMutator mutator); + object? UnMapper { get; } +} + +internal interface IRedisArgsMutator +{ + RedisKey Map(RedisKey key); + RedisChannel Map(RedisChannel channel); + + RedisKey UnMap(RedisKey key); + RedisChannel UnMap(RedisChannel channel); +} + +internal interface IRedisArgsResult +{ + T UnMap(IRedisArgsMutator mutator, T value); +} + +internal static class RedisArgsMutatorExtensions +{ + // these are used by the generated tuple-types via auto-database: each maps the key-bearing parts + // of an argument through the supplied mutator. They hang off IRedisArgsMutator (rather than the + // argument) so a call site reads consistently with the interface's own MapKey/MapChannel. + public static KeyValuePair Map( + this IRedisArgsMutator mutator, + KeyValuePair value) => + new(mutator.Map(value.Key), value.Value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TResult UnMap(this IRedisArgsMutator mutator, in TState state, TResult result) + where TState : struct, IRedisArgs + { + if (typeof(TResult) == typeof(RedisKey)) + { + var tmp = mutator.UnMap(Unsafe.As(ref result)); + return Unsafe.As(ref tmp); + } + else if (typeof(TResult) == typeof(RedisChannel)) + { + var tmp = mutator.UnMap(Unsafe.As(ref result)); + return Unsafe.As(ref tmp); + } + else if (typeof(TResult) == typeof(RedisValue)) + { + return result; // never mapped + } + else + { + return state.UnMapper is IRedisArgsResult unmap ? unmap.UnMap(mutator, result) : result; + } + } + + [return: NotNullIfNotNull("keys")] + public static RedisKey[]? Map(this IRedisArgsMutator mutator, RedisKey[]? keys) + { + if (keys is null || keys.Length is 0) return keys; + var arr = new RedisKey[keys.Length]; + for (int i = 0; i < arr.Length; i++) + { + arr[i] = mutator.Map(keys[i]); + } + return arr; + } + + [return: NotNullIfNotNull("pairs")] + public static KeyValuePair[]? Map( + this IRedisArgsMutator mutator, + KeyValuePair[]? pairs) + { + if (pairs is null || pairs.Length is 0) return pairs; + var arr = new KeyValuePair[pairs.Length]; + for (int i = 0; i < arr.Length; i++) + { + ref readonly KeyValuePair pair = ref pairs[i]; + arr[i] = new(mutator.Map(pair.Key), pair.Value); + } + return arr; + } + + public static StreamPosition Map(this IRedisArgsMutator mutator, StreamPosition value) => + new(mutator.Map(value.Key), value.Position); + + [return: NotNullIfNotNull("positions")] + public static StreamPosition[]? Map(this IRedisArgsMutator mutator, StreamPosition[]? positions) + { + if (positions is null || positions.Length is 0) return positions; + var arr = new StreamPosition[positions.Length]; + for (int i = 0; i < arr.Length; i++) + { + ref readonly StreamPosition position = ref positions[i]; + arr[i] = new(mutator.Map(position.Key), position.Position); + } + return arr; + } + + // the Execute/ExecuteAsync escape hatch takes a loosely-typed arg list in which any element may + // be a boxed RedisKey or RedisChannel; these rewrite just those entries (mirroring + // KeyPrefixed.ToInner), copying only when there is something to rewrite so the common call allocates nothing. + [return: NotNullIfNotNull("args")] + public static object[]? Map(this IRedisArgsMutator mutator, object[]? args) + { + if (args is null || args.Length is 0) return args; + object[]? copy = null; + for (int i = 0; i < args.Length; i++) + { + if (args[i] is RedisKey key) + { + (copy ??= (object[])args.Clone())[i] = mutator.Map(key); + } + else if (args[i] is RedisChannel channel) + { + (copy ??= (object[])args.Clone())[i] = mutator.Map(channel); + } + } + return copy ?? args; + } + + [return: NotNullIfNotNull("args")] + public static ICollection? Map(this IRedisArgsMutator mutator, ICollection? args) + { + if (args is null || args.Count is 0) return args; + bool any = false; + foreach (var arg in args) + { + if (arg is RedisKey or RedisChannel) + { + any = true; + break; + } + } + if (!any) return args; + + var copy = new object[args.Count]; + int i = 0; + foreach (var arg in args) + { + copy[i++] = arg switch + { + RedisKey key => mutator.Map(key), + RedisChannel channel => mutator.Map(channel), + _ => arg, + }; + } + return copy; + } + + public static SortedSetPopResult UnMap(this IRedisArgsMutator mutator, SortedSetPopResult value) => + value.IsNull ? SortedSetPopResult.Null : new(mutator.Map(value.Key), value.Entries); + + public static ListPopResult UnMap(this IRedisArgsMutator mutator, ListPopResult value) => + value.IsNull ? ListPopResult.Null : new(mutator.Map(value.Key), value.Values); +} diff --git a/src/StackExchange.Redis/Availability/CircuitBreaker.cs b/src/StackExchange.Redis/Availability/CircuitBreaker.cs index feaa653993..07e72d3b51 100644 --- a/src/StackExchange.Redis/Availability/CircuitBreaker.cs +++ b/src/StackExchange.Redis/Availability/CircuitBreaker.cs @@ -35,13 +35,14 @@ public class Builder private static readonly TimeSpan DefaultMetricsWindowSize = TimeSpan.FromSeconds(2); internal static CircuitBreaker DefaultInstance = new DefaultCircuitBreaker( - DefaultFailureRateThreshold, - DefaultMinimumNumberOfFailures, - DefaultMetricsWindowSize, +#pragma warning disable SA1114 // Parameter list should follow declaration - false positive: the #if directive splits the argument list #if NET8_0_OR_GREATER - timeProvider: null, + null, #endif - trackedExceptions: null); +#pragma warning restore SA1114 + DefaultFailureRateThreshold, + DefaultMinimumNumberOfFailures, + DefaultMetricsWindowSize); /// /// Percentage of failures to trigger circuit breaker. @@ -73,34 +74,28 @@ public class Builder public CircuitBreaker Create() { if ((FailureRateThreshold is DefaultFailureRateThreshold - & MinimumNumberOfFailures is DefaultMinimumNumberOfFailures #if NET8_0_OR_GREATER & TimeProvider is null #endif - & TrackedExceptions is null) + & MinimumNumberOfFailures is DefaultMinimumNumberOfFailures) && MetricsWindowSize == DefaultMetricsWindowSize) return DefaultInstance; return new DefaultCircuitBreaker( - FailureRateThreshold, - MinimumNumberOfFailures, - MetricsWindowSize, +#pragma warning disable SA1114 // Parameter list should follow declaration - false positive: the #if directive splits the argument list #if NET8_0_OR_GREATER TimeProvider, #endif - TrackedExceptions); +#pragma warning restore SA1114 + FailureRateThreshold, + MinimumNumberOfFailures, + MetricsWindowSize); } /// /// Create a new circuit-breaker instance. /// public static implicit operator CircuitBreaker(Builder builder) => builder.Create(); - - /// - /// Exceptions that count as failures. When null, - /// and are assumed. - /// - public Type[]? TrackedExceptions { get; set; } } /// @@ -108,30 +103,40 @@ public CircuitBreaker Create() /// public abstract Accumulator CreateAccumulator(); + internal static bool DefaultIsFailure(in FaultContext fault) + { + if (fault.ConnectionFailureType is not ConnectionFailureType.None) return true; + switch (fault.ErrorKind) + { + // what things *don't* trip the breaker? + case RedisErrorKind.None: // not even flagged + case RedisErrorKind.UnknownCommand: // application failure + case RedisErrorKind.ExecAbort: // transient to one command + case RedisErrorKind.WrongType: // application failure + case RedisErrorKind.NoPermission: // using the wrong keys? + case RedisErrorKind.UnknownError: // not sure what it is, but it starts ERR + case RedisErrorKind.Unknown: // pretty much anything we don't recognize; should we assume this is BAD? + return false; + default: + return true; + } + } + /// /// Collates observations for a connection. /// - public abstract class Accumulator + public abstract class Accumulator() { /// - /// Record a message outcome, and indicate whether the connection is considered healthy. + /// Record a message outcome. /// - /// True if the connection is still considered healthy. /// struct arg here is in case we want to add more things later - public abstract bool ObserveResult(in CircuitBreakerContext context); + public abstract void ObserveResult(in FaultContext fault); - internal bool ObserveResult(Exception? fault) - { - // only evaluate state upon failure; don't pay that overhead for success, just increment the counters - bool evaluate = fault is not null; - var ctx = new CircuitBreakerContext(fault, evaluate: evaluate); - bool healthy = ObserveResult(in ctx); - - // when we didn't ask for an evaluation, the returned verdict is meaningless: a custom - // implementation might return default(false) without having actually computed anything. - // never treat a non-evaluating observation as unhealthy - only a genuine evaluation may trip. - return !evaluate || healthy; - } + /// + /// Indicate whether a given fault should be considered a failure for the . + /// + protected virtual bool IsFailure(in FaultContext fault) => DefaultIsFailure(in fault); /// /// Indicate whether the connection is currently considered healthy, without recording an observation. @@ -143,34 +148,23 @@ internal bool ObserveResult(Exception? fault) /// Discard all accumulated observations, returning to a clean state. /// public abstract void Reset(); - } - /// - /// Provides information about a circuit-breaker test. - /// - public readonly struct CircuitBreakerContext(Exception? fault, bool evaluate = true) - { - /// - /// Was the operation a success. - /// - [MemberNotNullWhen(false, nameof(Fault))] - public bool Success => fault is null; - - /// - /// The fault associated with the operation. - /// - public Exception? Fault => fault; - - internal bool Evaluate => evaluate; - } + internal bool Trip(Exception? fault) + { + if (fault is not null) + { + var ctx = new FaultContext(fault); + if (IsFailure(ctx)) + { + ObserveResult(ctx); + return !IsHealthy(); + } + // otherwise, treat as success for the purposes of counting + } - private enum ExceptionStrategy - { - Default, - Any, - None, - CustomOpen, - CustomSealed, + ObserveResult(in FaultContext.Success); + return false; // never trip through success + } } private sealed class NulCircuitBreaker : CircuitBreaker @@ -183,15 +177,15 @@ private sealed class NulAccumulator : Accumulator { public static readonly NulAccumulator AccumulatorInstance = new(); private NulAccumulator() { } - public override bool ObserveResult(in CircuitBreakerContext context) => true; + public override void ObserveResult(in FaultContext context) { } public override bool IsHealthy() => true; public override void Reset() { } } + + // note we leave IsConnectionFault alone - that would impact RetryDatabase, where this is the key } private sealed class DefaultCircuitBreaker : CircuitBreaker { - private readonly ExceptionStrategy _trackingStrategy; - private readonly Type[] _trackedExceptions; private readonly double _failureRateThreshold; private readonly int _minimumNumberOfFailures; @@ -217,15 +211,13 @@ private long GetEpoch() => / _bucketTicks; public DefaultCircuitBreaker( - double failureRateThreshold, - int minimumNumberOfFailures, - TimeSpan metricsWindowSize, #if NET8_0_OR_GREATER TimeProvider? timeProvider, #endif - Type[]? trackedExceptions) + double failureRateThreshold, + int minimumNumberOfFailures, + TimeSpan metricsWindowSize) { - _trackedExceptions = CheckExceptions(trackedExceptions, out _trackingStrategy); _failureRateThreshold = failureRateThreshold; _minimumNumberOfFailures = minimumNumberOfFailures; @@ -302,11 +294,8 @@ public void Reset() } } - public override bool ObserveResult(in CircuitBreakerContext context) + public override void ObserveResult(in FaultContext result) { - // not-tracked failures (based on exception type) count as "success" for the purposes of circuit breaking - bool countAsSuccess = context.Success || !breaker.IsTracked(context.Fault); - // which time-slice are we in, and where does it live in the ring? long epoch = breaker.GetEpoch(); int index = (int)(epoch % BucketCount); @@ -318,9 +307,7 @@ public override bool ObserveResult(in CircuitBreakerContext context) // only getting results one at a time, so we shouldn't be over-stomping much *anyway* Span buckets = _buckets; // *not* a payload copy; this is in-place over the data ref Bucket bucket = ref buckets[index]; - bucket.Count(epoch, countAsSuccess); - - return !context.Evaluate || Evaluate(epoch); + bucket.Count(epoch, success: !result.IsFault); } public override bool IsHealthy() => Evaluate(breaker.GetEpoch()); @@ -362,90 +349,5 @@ public override void Reset() } } } - - private static Type[] CheckExceptions(Type[]? tracked, out ExceptionStrategy strategy) - { - if (tracked is null) - { - strategy = ExceptionStrategy.Default; - return []; - } - if (tracked.Length is 0) - { - strategy = ExceptionStrategy.None; - return []; - } - strategy = ExceptionStrategy.CustomOpen; - - static bool Contains(Type[] array, Type type) - { - for (int i = 0; i < array.Length; i++) - { - if (array[i] == type) return true; - } - - return false; - } - - // if we have Exception anywhere: we'll track everything - if (Contains(tracked, typeof(Exception))) - { - strategy = ExceptionStrategy.Any; - return []; - } - - if (tracked.Length is 2 - && Contains(tracked, typeof(RedisConnectionException)) - && Contains(tracked, typeof(RedisTimeoutException))) - { - strategy = ExceptionStrategy.Default; - return []; - } - - // finally, see if they're all sealed types - strategy = ExceptionStrategy.CustomSealed; - foreach (var exception in tracked) - { - if (!exception.IsSealed) - { - strategy = ExceptionStrategy.CustomOpen; - break; - } - } - return tracked; - } - - private bool IsTracked(Exception? fault) - { - if (fault is null) return false; - switch (_trackingStrategy) - { - case ExceptionStrategy.Any: - return true; - case ExceptionStrategy.Default: - return fault is RedisTimeoutException or RedisConnectionException; - case ExceptionStrategy.None: - return false; - } - - var span = _trackedExceptions.AsSpan(); - var actualType = fault.GetType(); - // check for exact matches - foreach (var testType in span) - { - if (ReferenceEquals(testType, actualType)) return true; - } - - if (_trackingStrategy is ExceptionStrategy.CustomOpen) - { - // we need to check for subclasses (more expensive) - foreach (var testType in span) - { - if (!testType.IsSealed && testType.IsAssignableFrom(actualType)) return true; - } - } - - return false; - } } } diff --git a/src/StackExchange.Redis/Availability/DatabaseExtensions.cs b/src/StackExchange.Redis/Availability/DatabaseExtensions.cs new file mode 100644 index 0000000000..9733bf4853 --- /dev/null +++ b/src/StackExchange.Redis/Availability/DatabaseExtensions.cs @@ -0,0 +1,20 @@ +using System.Diagnostics.CodeAnalysis; +using RESPite; + +namespace StackExchange.Redis.Availability +{ + /// + /// Provides availability-related extension methods (such as ) to database instances. + /// + public static class DatabaseExtensions + { + /// + /// Automatically retry operations when connection failure occurs. This has deep integration with + /// SE.Redis concepts, so can respond to server failover events, apply circuit-breaker rules, and + /// respect command effect categorization. + /// + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + public static IDatabaseAsync WithRetry(this IDatabaseAsync database, RetryPolicy retryPolicy) + => new RetryDatabase(database, retryPolicy); + } +} diff --git a/src/StackExchange.Redis/Availability/FaultContext.cs b/src/StackExchange.Redis/Availability/FaultContext.cs new file mode 100644 index 0000000000..4321630aff --- /dev/null +++ b/src/StackExchange.Redis/Availability/FaultContext.cs @@ -0,0 +1,100 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using RESPite; + +namespace StackExchange.Redis.Availability; + +/// +/// Provides information about a circuit-breaker test. +/// +[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +public readonly struct FaultContext +{ + private readonly Exception? _fault; + private readonly ConnectionFailureType _connectionFailureType; + private readonly CommandFlags _flags; + + internal static readonly FaultContext Success = default; + + /// + /// Create a new . + /// + /// The fault associated with the operation, or null on success. + public FaultContext(Exception fault) + { + _fault = fault; + + var kind = RedisErrorKind.None; + _connectionFailureType = ConnectionFailureType.None; + var flags = CommandFlags.None; + switch (fault) + { + case RedisServerException server: + kind = server.Kind; + flags = server.Flags; + break; + case RedisConnectionException connection: + _connectionFailureType = connection.FailureType; + kind = RedisErrorKind.ConnectionFault; + flags = connection.Flags; + break; + case RedisTimeoutException timeout: + kind = RedisErrorKind.Timeout; + flags = timeout.Flags; + break; + case TimeoutException: + kind = RedisErrorKind.Timeout; + break; + } + + if (kind is not RedisErrorKind.None & _connectionFailureType is ConnectionFailureType.None) + { + // fill in some blanks + switch (kind) + { + case RedisErrorKind.Loading: + _connectionFailureType = ConnectionFailureType.Loading; + break; + case RedisErrorKind.NoAuth: + case RedisErrorKind.WrongPass: + _connectionFailureType = ConnectionFailureType.AuthenticationFailure; + break; + } + } + + flags &= Message.UserSelectableFlags; + if ((flags & Message.MaskRetryCategory) is 0) + { + // if no retry category found: assume the worst + flags |= CommandFlags.CommandRetryNever; + } + _flags = flags; + ErrorKind = kind; + } + + /// + /// Indicates whether a fault is present. + /// + [MemberNotNullWhen(true, nameof(Fault))] + public bool IsFault => _fault is not null; // excludes: default(FaultContext) + + /// + /// The fault associated with the operation. + /// + public Exception? Fault => _fault; + + /// + /// Get any command-flags associated with the operation. + /// + public CommandFlags Flags => _flags; + + /// + /// The classified server error condition associated with the fault, if any. + /// + public RedisErrorKind ErrorKind { get; } + + /// + /// The connection failure type associated with the fault, if any. + /// + public ConnectionFailureType ConnectionFailureType => _connectionFailureType; +} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs index 77e0fe4d53..7bfc180331 100644 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs @@ -1,15 +1,31 @@ using System; +using System.Threading; +using StackExchange.Redis.Interfaces; namespace StackExchange.Redis.Availability; internal sealed partial class MultiGroupDatabase(MultiGroupMultiplexer parent, int database, object? asyncState) - : IDatabase + : IDatabase, IInternalDatabaseAsync { public object? AsyncState => asyncState; public int Database => database < 0 ? GetActiveDatabase().Database : database; public IConnectionMultiplexer Multiplexer => parent; + CancellationToken IInternalDatabaseAsync.GetNextFailover() => parent.GetNextFailover(); + + DatabaseFeatureFlags IInternalDatabaseAsync.GetFeatures(out string name) + { + // fold in the currently-active member's features (when there is one) and label with its name + var active = TryGetActiveDatabase(); + var features = active is null ? DatabaseFeatureFlags.None : active.GetFeatures(out _); + name = ((IConnectionGroup)parent).ActiveMember?.Name ?? ""; + return features | DatabaseFeatureFlags.ConnectionGroup | DatabaseFeatureFlags.Failover; + } + + // uses the active member's name (via GetFeatures) when a member is active + public override string ToString() => this.BuildString(); + // for high DB numbers this might allocate even for null async-state scenarios; unavoidable for now private IDatabase GetActiveDatabase() => parent.Active.GetDatabase(database, asyncState); diff --git a/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs index 378489cd4f..4ff4eb8720 100644 --- a/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs @@ -320,12 +320,46 @@ internal void UpdateLatency() internal sealed partial class MultiGroupMultiplexer : IConnectionGroup { - private ConnectionMultiplexer? _active; + private ActiveStub _activeStub = new(null); + + private void SetActive(ConnectionMultiplexer? active) + { + ActiveStub? newObj = null; + while (true) + { + var oldObj = Volatile.Read(ref _activeStub); + + // is it already the same? + if (ReferenceEquals(oldObj.Active, active)) + { + newObj?.Dispose(); // never actually released to the world + return; // nothing to do! + } + + newObj ??= new(active); + if (ReferenceEquals(Interlocked.CompareExchange(ref _activeStub, newObj, oldObj), oldObj)) + { + // successful swap; flag the old one as failed-over + oldObj.Cancel(false); + return; + } + + // race? redo from start, but we can keep our newObj if we created one + } + } + + public CancellationToken GetNextFailover() => _activeStub.Token; + + private sealed class ActiveStub(ConnectionMultiplexer? active) : CancellationTokenSource + { + public ConnectionMultiplexer? Active => active; + } + private ConnectionGroupMember[] _members; public override string ToString() { - var muxer = _active; + var muxer = _activeStub.Active; ConnectionGroupMember? member = null; if (muxer is not null) { @@ -348,7 +382,7 @@ internal ConnectionMultiplexer Active { get { - return _active ?? Throw(); + return _activeStub.Active ?? Throw(); [DoesNotReturn] static ConnectionMultiplexer Throw() => @@ -357,12 +391,12 @@ static ConnectionMultiplexer Throw() => } // non-throwing twin of Active, for callers that have a trivial answer when the group is fully down - internal ConnectionMultiplexer? TryGetActive() => _active; + internal ConnectionMultiplexer? TryGetActive() => _activeStub.Active; // a completed "no endpoint" result, shared by the database/subscriber facades when the group is fully down internal static readonly Task NoEndpoint = Task.FromResult(null); - private ConnectionGroupMember? GetActiveMember() => GetMember(_active); + private ConnectionGroupMember? GetActiveMember() => GetMember(_activeStub.Active); private ConnectionGroupMember? GetMember(ConnectionMultiplexer? muxer) { @@ -433,7 +467,7 @@ private MultiGroupMultiplexer(ConnectionGroupMember[] members, MultiGroupOptions { _options = options; _members = members; - _active = null; + SetActive(null); _connectionFailedWithCircuitBreaker = OnMemberConnectionFailed; // multiplexers should already be attached (ConnectAsync sets them before constructing us) @@ -575,7 +609,7 @@ private long GetFailbackFailureCutoff() internal void SelectPreferredGroup() { if (_disposed) return; - var existingActive = _active; + var existingActive = _activeStub.Active; ConnectionGroupMember? preferredMember = null, previousMember = null; var members = _members; foreach (var member in members) @@ -594,7 +628,7 @@ internal void SelectPreferredGroup() } } - _active = preferredMember?.Multiplexer; + SetActive(preferredMember?.Multiplexer); if (preferredMember is not null && !ReferenceEquals(preferredMember, previousMember)) { @@ -610,7 +644,7 @@ internal void SelectPreferredGroup() private List DropAll() { _pollCancellation.Cancel(); // stop the polling loop promptly (idempotent) - _active = null; + SetActive(null); var members = Interlocked.Exchange(ref _members, []); if (members.Length is 0) return []; var muxers = new List(members.Length); @@ -671,8 +705,8 @@ public bool PreserveAsyncOrder // Unlike most members, these intentionally do *not* go via Active (which throws when no member is // available); callers routinely use IsConnected/IsConnecting as a pre-flight check and expect a // 'false' result - not an exception - when the entire group is down. - public bool IsConnected => _active?.IsConnected ?? false; - public bool IsConnecting => _active?.IsConnecting ?? false; + public bool IsConnected => _activeStub.Active?.IsConnected ?? false; + public bool IsConnecting => _activeStub.Active?.IsConnecting ?? false; [Obsolete] public bool IncludeDetailInExceptions diff --git a/src/StackExchange.Redis/Availability/MultiGroupSubscriber.Tracking.cs b/src/StackExchange.Redis/Availability/MultiGroupSubscriber.Tracking.cs index eb560004de..a029eef569 100644 --- a/src/StackExchange.Redis/Availability/MultiGroupSubscriber.Tracking.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupSubscriber.Tracking.cs @@ -60,7 +60,7 @@ private static Action FilteredHandler(MultiGroupMultip // invoke a handler only if the active member is the one we expect return (channel, value) => { - if (ReferenceEquals(parent._active, active.Multiplexer)) + if (ReferenceEquals(parent._activeStub.Active, active.Multiplexer)) { handler(channel, value); } @@ -89,7 +89,7 @@ static async Task ForwardFilteredMessagesAsync( { while (readFrom.TryRead(out var message)) { - if (ReferenceEquals(parent._active, active.Multiplexer)) + if (ReferenceEquals(parent._activeStub.Active, active.Multiplexer)) { // Because of the switchover being imperfect, we can't guarantee exactly one writer, so // we need to be synchronized; in reality, it will *almost never* be contended, so diff --git a/src/StackExchange.Redis/Availability/RetryDatabase.cs b/src/StackExchange.Redis/Availability/RetryDatabase.cs new file mode 100644 index 0000000000..8dfa132a18 --- /dev/null +++ b/src/StackExchange.Redis/Availability/RetryDatabase.cs @@ -0,0 +1,216 @@ +using System; +using System.Diagnostics; +using System.IO.Pipes; +using System.Threading; +using System.Threading.Tasks; +using StackExchange.Redis.Interfaces; + +namespace StackExchange.Redis.Availability; + +[AutoDatabase] +internal partial class RetryDatabase : IDatabaseAsync, IRedisArgsMutator, IInternalDatabaseAsync +{ + // Note: we very deliberately do not include synchronous support for retry; it is inherently delay-ish + + // Note that only transient faults result in retries; this is defined by the RetryPolicy, along with + // understanding the category. The default RetryPolicy works the same as the default CircuitBreaker. + DatabaseFeatureFlags IInternalDatabaseAsync.GetFeatures(out string name) + => _inner.GetFeatures(out name) | DatabaseFeatureFlags.Retry; + + /// + public override string ToString() => this.BuildString(); + + private readonly IDatabaseAsync _inner; + private readonly int _maxBeforeFailover, _maxAttempts, _delayMillis, _jitterMillis, _failoverMillis; + private readonly RetryPolicy _policy; + + public CancellationToken GetNextFailover() + => _maxAttempts > 1 & _maxBeforeFailover < _maxAttempts ? _inner.GetNextFailover() : CancellationToken.None; + + public RetryDatabase(IDatabaseAsync inner, RetryPolicy policy) + // cannot nest retry, and cannot issue retries *inside* a batch/transaction + : this(inner, policy, inner.RejectFlags(DatabaseFeatureFlags.Batch | DatabaseFeatureFlags.Transaction | DatabaseFeatureFlags.Retry)) + { + } + + // test-only: supply the inner database's feature set directly (in particular whether failover is + // available), instead of probing a live inner - so that failover behaviour can be exercised over a + // null inner without a full IDatabaseAsync double. + internal RetryDatabase(IDatabaseAsync inner, RetryPolicy policy, DatabaseFeatureFlags features) + { + _policy = policy; + + // capture config locally rather than constant cross-object lookups (plus: mutability) + _maxBeforeFailover = (features & DatabaseFeatureFlags.Failover) == 0 ? int.MaxValue : policy.MaxAttemptsBeforeFailover; + _maxAttempts = policy.MaxAttempts; + if (_maxBeforeFailover == _maxAttempts) _maxBeforeFailover = int.MaxValue; // then we'll never look + + // guard the failover threshold: values < 1 can never be hit by the loop counter (which starts at 1), + // so they would *silently* disable failover rather than erroring; validate the raw policy value + if (policy.MaxAttemptsBeforeFailover < 1) throw new ArgumentOutOfRangeException(nameof(policy.MaxAttemptsBeforeFailover)); + _delayMillis = policy.DelayMilliseconds; + _failoverMillis = policy.FailoverMilliseconds; + _jitterMillis = policy.JitterMilliseconds; + if (_delayMillis < 0) throw new ArgumentOutOfRangeException(nameof(policy.RetryDelay)); + if (_jitterMillis < 0) throw new ArgumentOutOfRangeException(nameof(policy.JitterMax)); + if (_failoverMillis < 0) throw new ArgumentOutOfRangeException(nameof(policy.FailoverDelay)); + _inner = inner; + } + + public int Database => _inner.Database; + + public IConnectionMultiplexer Multiplexer => _inner.Multiplexer; + + // the generated explicit interface implementations funnel every call through these two + // overloads: the arguments are captured in a generated state struct and replayed against + // the inner database via a cacheable static projection (no per-call closure). Retry/failover + // policy will live here in due course; for now it is a straight pass-through. + + // async counterparts (Task / Task); these get their own retry/failover policy in due course. + private async Task ExecuteAsync(TState state, Func> operation) + where TState : struct, IRedisArgs + { + state.Map(this); + + int attempt = 0; + TResult result; + // note we need to capture this *before* the attempt - otherwise the failover could happen + // between the failed attempt and fetching this, and we'd miss it + CancellationToken failover = GetNextFailover(); + while (true) + { + try + { + result = await operation(state, _inner).ConfigureAwait(false); + break; + } + catch (Exception ex) when (CanRetry(++attempt, ex, ref failover, out var delay)) + { + await FailoverOrDelayAsync(delay).ConfigureAwait(false); + } + } + // post-process results outside the loop + return this.UnMap(state, result); + } + + private async Task ExecuteAsync(TState state, Func operation) + where TState : struct, IRedisArgs + { + state.Map(this); + + int attempt = 0; + // note we need to capture this *before* the attempt - otherwise the failover could happen + // between the failed attempt and fetching this, and we'd miss it + CancellationToken failover = GetNextFailover(); + while (true) + { + try + { + await operation(state, _inner).ConfigureAwait(false); + break; + } + catch (Exception ex) when (CanRetry(++attempt, ex, ref failover, out var delay)) + { + await FailoverOrDelayAsync(delay).ConfigureAwait(false); + } + } + // (nothing to post-process) + } + + internal bool CanRetry( + int attempt, + Exception fault, + ref CancellationToken failover, + out CancellationToken delay) + { + delay = CancellationToken.None; + if (attempt >= _maxAttempts) + { + // all used up + return false; + } + + // ask the retry policy for advice, and mask off the bits we know about + FaultContext ctx = new(fault); + var policy = _policy.CanRetry(ctx) & + (RetryPolicy.RetryPolicyResult.FailoverServer | RetryPolicy.RetryPolicyResult.SameServer); + if (policy is 0) + { + // retry policy says: nope + return false; + } + + if (policy is RetryPolicy.RetryPolicyResult.FailoverServer) + { + // we can *only* retry on a different server; is failover available? + delay = failover; + failover = CancellationToken.None; // only failover once + return delay.CanBeCanceled; + } + + if (attempt == _maxBeforeFailover) + { + // by count, we should really switch over to the failover now; is failover available *and* are we allowed? + delay = failover; + failover = CancellationToken.None; // only failover once + return delay.CanBeCanceled & (policy & RetryPolicy.RetryPolicyResult.FailoverServer) != 0; + } + + // can we pause and retry on the same server? + return (policy & RetryPolicy.RetryPolicyResult.SameServer) != 0; + } + + private Task FailoverOrDelayAsync(CancellationToken delay) + { + if (delay.CanBeCanceled) + { + return AwaitFailover(delay); + } + + // this is just a routine wait between operations; await delay+jitter + return Task.Delay(_delayMillis + ServerSelectionStrategy.SharedRandom.Next(_jitterMillis), CancellationToken.None); + } + private async Task AwaitFailover(CancellationToken failover) + { + if (!failover.IsCancellationRequested) + { + // failover hasn't happened yet; allow up to "delay" time for that + try + { + await Task.Delay(_failoverMillis, failover).ConfigureAwait(false); + } + catch (OperationCanceledException) when (failover.IsCancellationRequested) + { + // we observed a failover, nice! + } + } + + // either way, we need to add jitter onto that; we can't add in the original delay, because if the failover + // happened before the timeout+jitter, all the awaiters would stampede + await Task.Delay(ServerSelectionStrategy.SharedRandom.Next(_jitterMillis), CancellationToken.None).ConfigureAwait(false); + } + + void IRedisAsync.Wait(Task task) => _inner.Wait(task); + T IRedisAsync.Wait(Task task) => _inner.Wait(task); + void IRedisAsync.WaitAll(Task[] tasks) => _inner.WaitAll(tasks); + bool IRedisAsync.TryWait(Task task) => _inner.TryWait(task); + + // Methods the generator deliberately skips (see AutoDatabaseGenerator.SkipMethod): the Wait + // family, the synchronous IsConnected probe, and the streaming IEnumerable/IAsyncEnumerable scans + // don't fit the capture-and-replay shape, so they are implemented by hand. + // IsConnected is a straight pass-through: it is a cheap status check, not a server round-trip to retry. + bool IDatabaseAsync.IsConnected(RedisKey key, CommandFlags flags) => _inner.IsConnected(key, flags); + + System.Collections.Generic.IAsyncEnumerable IDatabaseAsync.HashScanAsync(RedisKey key, RedisValue pattern, int pageSize, long cursor, int pageOffset, CommandFlags flags) => throw new NotImplementedException(); + System.Collections.Generic.IAsyncEnumerable IDatabaseAsync.HashScanNoValuesAsync(RedisKey key, RedisValue pattern, int pageSize, long cursor, int pageOffset, CommandFlags flags) => throw new NotImplementedException(); + System.Collections.Generic.IAsyncEnumerable IDatabaseAsync.SetScanAsync(RedisKey key, RedisValue pattern, int pageSize, long cursor, int pageOffset, CommandFlags flags) => throw new NotImplementedException(); + System.Collections.Generic.IAsyncEnumerable IDatabaseAsync.VectorSetRangeEnumerateAsync(RedisKey key, RedisValue start, RedisValue end, long count, Exclude exclude, CommandFlags flags) => throw new NotImplementedException(); + System.Collections.Generic.IAsyncEnumerable IDatabaseAsync.SortedSetScanAsync(RedisKey key, RedisValue pattern, int pageSize, long cursor, int pageOffset, CommandFlags flags) => throw new NotImplementedException(); + + RedisKey IRedisArgsMutator.Map(RedisKey key) => key; + + RedisChannel IRedisArgsMutator.Map(RedisChannel channel) => channel; + + RedisKey IRedisArgsMutator.UnMap(RedisKey key) => key; + RedisChannel IRedisArgsMutator.UnMap(RedisChannel channel) => channel; +} diff --git a/src/StackExchange.Redis/Availability/RetryPolicy.cs b/src/StackExchange.Redis/Availability/RetryPolicy.cs new file mode 100644 index 0000000000..447424b2fc --- /dev/null +++ b/src/StackExchange.Redis/Availability/RetryPolicy.cs @@ -0,0 +1,134 @@ +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using RESPite; + +namespace StackExchange.Redis.Availability; + +/// +/// Configures how messages can be retried due to connection / transient faults. Other faults (such as invalid +/// usage) are not retried. +/// +[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +public class RetryPolicy +{ + /// + /// The maximum number of times an operation can be attempted. Defaults to 3. + /// + public int MaxAttempts { get; set; } = 3; + + /// + /// The maximum number of times to retry an operation before waiting for failover; this only currently + /// applies to multi-group connections created via ConnectionMultiplexer.ConnectGroupAsync. + /// Defaults to 1. + /// + public int MaxAttemptsBeforeFailover { get; set; } = 1; + + private int _delayMillis = 1000, _jitterMillis = 500, _failoverMillis = 5000; + + /// + /// Gets the time to wait between retries that are *not* dependent on a failover happening. Defaults to 1 second. + /// + public TimeSpan RetryDelay + { + get => TimeSpan.FromMilliseconds(_delayMillis); + set => _delayMillis = checked((int)value.TotalMilliseconds); + } + + /// + /// Gets the time to wait for a failover, after attempts. Only one + /// failover attempt is awaited. When this applies, is ignored, + /// but is still respected. Defaults to 5 seconds. + /// + public TimeSpan FailoverDelay + { + get => TimeSpan.FromMilliseconds(_failoverMillis); + set => _failoverMillis = checked((int)value.TotalMilliseconds); + } + + /// + /// Gets or sets the upper bound for jitter - additional random delay between retries to prevent stampedes. + /// Defaults to 0.5 seconds, meaning between 0 and 0.5 *additional* seconds on top of . + /// + public TimeSpan JitterMax + { + get => TimeSpan.FromMilliseconds(_jitterMillis); + set => _jitterMillis = checked((int)value.TotalMilliseconds); + } + + internal int DelayMilliseconds => _delayMillis; + internal int JitterMilliseconds => _jitterMillis; + internal int FailoverMilliseconds => _failoverMillis; + + /// + /// Gets or sets the max side-effect category that will be retried; defaults to . + /// + public CommandFlags MaxCommandRetryCategory + { + get => _maxCommandRetryCategory; + set + { + if ((value & Message.MaskRetryCategory) is 0 | (value & ~Message.MaskRetryCategory) is not 0) + throw new InvalidOperationException("Valid CommandRetry* flags should be specified"); + _maxCommandRetryCategory = value; + } + } + + private CommandFlags _maxCommandRetryCategory = CommandFlags.CommandRetryWriteLastWins; + + /// + /// Controls which operations can be repeated, optionally indicating that this should progress to + /// a new server. + /// + public virtual RetryPolicyResult CanRetry(in FaultContext fault) + { + var actual = fault.Flags & Message.MaskRetryCategory; + if (actual is 0) actual = CommandFlags.CommandRetryWriteAccumulating; // if not set, assume similar to INCR + + if (actual is CommandFlags.CommandRetryNever) + { + // explicitly disabled at command level + return RetryPolicyResult.None; + } + + if (actual > MaxCommandRetryCategory) // note this also covers CommandRetryAlways + { + // side-effects are beyond what the policy allows + return RetryPolicyResult.None; + } + + if (CircuitBreaker.DefaultIsFailure(in fault)) + { + // assume we can send it everywhere + var result = RetryPolicyResult.SameServer | RetryPolicyResult.FailoverServer; + if ((fault.Flags & Message.CommandServerSpecific) != 0) + result &= ~RetryPolicyResult.FailoverServer; + return result; + } + + // do not retry + return RetryPolicyResult.None; + } + + /// + /// Indicates the result of a query. + /// + [Flags] + public enum RetryPolicyResult + { + /// + /// None; the operation should not be retried. + /// + None = 0, + + /// + /// The operation can be retried on the same server. + /// + SameServer = 1, + + /// + /// The operation can be retried on a different server after a failover operation. + /// + FailoverServer = 2, + } +} diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.Sentinel.cs b/src/StackExchange.Redis/ConnectionMultiplexer.Sentinel.cs index 6f11fa2f34..658f6920c0 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.Sentinel.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.Sentinel.cs @@ -169,6 +169,7 @@ public ConnectionMultiplexer GetSentinelMasterConnection(ConfigurationOptions co { throw new RedisConnectionException( ConnectionFailureType.UnableToConnect, + CommandFlags.None, "Sentinel: The ConnectionMultiplexer is not a Sentinel connection. Detected as: " + ServerSelectionStrategy.ServerType); } @@ -204,6 +205,7 @@ public ConnectionMultiplexer GetSentinelMasterConnection(ConfigurationOptions co { throw new RedisConnectionException( ConnectionFailureType.UnableToConnect, + CommandFlags.None, $"Sentinel: Failed connecting to configured primary for service: {config.ServiceName}"); } @@ -271,6 +273,7 @@ public ConnectionMultiplexer GetSentinelMasterConnection(ConfigurationOptions co { throw new RedisConnectionException( ConnectionFailureType.UnableToConnect, + CommandFlags.None, $"Sentinel: Failed connecting to configured primary for service: {config.ServiceName}"); } @@ -418,7 +421,7 @@ internal void SwitchPrimary(EndPoint? switchBlame, ConnectionMultiplexer connect // Get new primary - try twice EndPoint newPrimaryEndPoint = GetConfiguredPrimaryForService(serviceName) ?? GetConfiguredPrimaryForService(serviceName) - ?? throw new RedisConnectionException(ConnectionFailureType.UnableToConnect, $"Sentinel: Failed connecting to switch primary for service: {serviceName}"); + ?? throw new RedisConnectionException(ConnectionFailureType.UnableToConnect, CommandFlags.None, $"Sentinel: Failed connecting to switch primary for service: {serviceName}"); connection.currentSentinelPrimaryEndPoint = newPrimaryEndPoint; diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index 769a64b50f..86fd7783d0 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -2036,7 +2036,7 @@ private WriteResult TryPushMessageToBridgeSync(Message message, ResultProcess WriteResult.Success => throw new ArgumentOutOfRangeException(nameof(result), "Be sure to check result isn't successful before calling GetException."), WriteResult.NoConnectionAvailable => ExceptionFactory.NoConnectionAvailable(this, message, server), WriteResult.TimeoutBeforeWrite => ExceptionFactory.Timeout(this, null, message, server, result, bridge), - _ => ExceptionFactory.ConnectionFailure(RawConfig.IncludeDetailInExceptions, ConnectionFailureType.ProtocolFailure, "An unknown error occurred when writing the message", server), + _ => ExceptionFactory.ConnectionFailure(RawConfig.IncludeDetailInExceptions, ConnectionFailureType.ProtocolFailure, message.Flags, "An unknown error occurred when writing the message", server), }; [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA1816:Dispose methods should call SuppressFinalize", Justification = "Intentional observation")] diff --git a/src/StackExchange.Redis/Enums/CommandFlags.Category.cs b/src/StackExchange.Redis/Enums/CommandFlags.Category.cs new file mode 100644 index 0000000000..02abc3b8b3 --- /dev/null +++ b/src/StackExchange.Redis/Enums/CommandFlags.Category.cs @@ -0,0 +1,373 @@ +namespace StackExchange.Redis; + +internal static class CommandFlagsExtensions +{ + public static CommandFlags WithCategory(this CommandFlags flags, CommandFlags category) + // if the user hasn't already specified a category: use the category supplied + => ((flags & Message.MaskRetryCategory) is 0) ? flags | (category & Message.MaskRetryCategory) : flags; + + public static CommandFlags WithDefaultCategory(this CommandFlags flags, RedisCommand command) + { + if ((flags & Message.MaskRetryCategory) is 0) + { + // Get the suggested flags; note that the user might have included CommandServerSpecific, + // but we *also* suggest that below - we'll live with it, additively. + // Note also that some commands may have *conditionally* included their category based on + // rules specific to the parameters, for example SCAN 0 is not server specific, + // but SCAN 12341234 *is*. + flags |= DefaultCategory(command); + } + + return flags; + + static CommandFlags DefaultCategory(RedisCommand command) + { + // This is *not* using switch expressions very deliberately, because there are a *lot* of + // options in each; let's keep things vertical rather than horizontal. + switch (command) + { + // ========================================================================== + // CONNECTION / SESSION — node-agnostic, no keyspace side effects, safe to + // replay on a fresh connection. + // ========================================================================== + case RedisCommand.PING: + case RedisCommand.ECHO: + case RedisCommand.AUTH: + case RedisCommand.HELLO: + case RedisCommand.SELECT: + case RedisCommand.QUIT: + case RedisCommand.SUBSCRIBE: + case RedisCommand.UNSUBSCRIBE: + case RedisCommand.PSUBSCRIBE: + case RedisCommand.PUNSUBSCRIBE: + case RedisCommand.SSUBSCRIBE: + case RedisCommand.SUNSUBSCRIBE: + case RedisCommand.INFO: + case RedisCommand.TIME: + case RedisCommand.DBSIZE: + case RedisCommand.LASTSAVE: + case RedisCommand.COMMAND: + return CommandFlags.CommandRetryConnection; + + // CLIENT etc often use server-specific IDs + case RedisCommand.CLIENT: // note some can be considered admin, overridden locally + return CommandFlags.CommandRetryConnection | Message.CommandServerSpecific; + + // ========================================================================== + // READ-ONLY — no mutation, always safe to retry. + // ========================================================================== + case RedisCommand.GET: + case RedisCommand.MGET: + case RedisCommand.STRLEN: + case RedisCommand.GETRANGE: + case RedisCommand.EXISTS: + case RedisCommand.TYPE: + case RedisCommand.TTL: + case RedisCommand.PTTL: + case RedisCommand.EXPIRETIME: + case RedisCommand.PEXPIRETIME: + case RedisCommand.KEYS: + case RedisCommand.RANDOMKEY: + case RedisCommand.DUMP: + case RedisCommand.TOUCH: // technically bumps LRU/LFU state, but that's not a "real" side effect worth blocking retries over + case RedisCommand.OBJECT: + case RedisCommand.MEMORY: + case RedisCommand.SORT_RO: + case RedisCommand.SORT: // ignoring the STORE variant + case RedisCommand.LCS: + case RedisCommand.GETEX: // ignoring the TTL-mutating option variants + case RedisCommand.HGET: + case RedisCommand.HMGET: + case RedisCommand.HGETALL: + case RedisCommand.HKEYS: + case RedisCommand.HVALS: + case RedisCommand.HLEN: + case RedisCommand.HEXISTS: + case RedisCommand.HSTRLEN: + case RedisCommand.HRANDFIELD: + case RedisCommand.HPTTL: + case RedisCommand.HEXPIRETIME: + case RedisCommand.HPEXPIRETIME: + case RedisCommand.HGETEX: // ignoring TTL-mutating option variants + case RedisCommand.LLEN: + case RedisCommand.LRANGE: + case RedisCommand.LINDEX: + case RedisCommand.LPOS: + case RedisCommand.SMEMBERS: + case RedisCommand.SCARD: + case RedisCommand.SISMEMBER: + case RedisCommand.SMISMEMBER: + case RedisCommand.SRANDMEMBER: + case RedisCommand.SDIFF: + case RedisCommand.SINTER: + case RedisCommand.SINTERCARD: + case RedisCommand.SUNION: + case RedisCommand.ZCARD: + case RedisCommand.ZSCORE: + case RedisCommand.ZMSCORE: + case RedisCommand.ZRANK: + case RedisCommand.ZREVRANK: + case RedisCommand.ZCOUNT: + case RedisCommand.ZLEXCOUNT: + case RedisCommand.ZRANGE: + case RedisCommand.ZREVRANGE: + case RedisCommand.ZRANGEBYSCORE: + case RedisCommand.ZREVRANGEBYSCORE: + case RedisCommand.ZRANGEBYLEX: + case RedisCommand.ZREVRANGEBYLEX: + case RedisCommand.ZRANDMEMBER: + case RedisCommand.ZDIFF: + case RedisCommand.ZINTER: + case RedisCommand.ZUNION: + case RedisCommand.ZINTERCARD: + case RedisCommand.BITCOUNT: + case RedisCommand.BITPOS: + case RedisCommand.GETBIT: + case RedisCommand.PFCOUNT: + case RedisCommand.GEOPOS: + case RedisCommand.GEODIST: + case RedisCommand.GEOHASH: + case RedisCommand.GEOSEARCH: + case RedisCommand.XLEN: + case RedisCommand.XRANGE: + case RedisCommand.XREVRANGE: + case RedisCommand.XREAD: // group-less read only; XREADGROUP is handled separately below + case RedisCommand.XPENDING: + case RedisCommand.XINFO: + case RedisCommand.EVAL_RO: + case RedisCommand.EVALSHA_RO: + return CommandFlags.CommandRetryReadOnly; + + // PUBSUB/SCAN/etc are *basically* read-only, but make limited sense between nodes + case RedisCommand.PUBSUB: + case RedisCommand.SCAN: + case RedisCommand.ZSCAN: + case RedisCommand.SSCAN: + case RedisCommand.HSCAN: + return CommandFlags.CommandRetryReadOnly | Message.CommandServerSpecific; + + // ========================================================================== + // WRITE - CHECKED — inherently conditional/idempotent; a retry either + // no-ops or fails in a way that leaves the end-state identical. + // ========================================================================== + case RedisCommand.SETNX: + case RedisCommand.MSETNX: + case RedisCommand.HSETNX: + case RedisCommand.DEL: + case RedisCommand.UNLINK: + case RedisCommand.PERSIST: + case RedisCommand.RENAMENX: + case RedisCommand.COPY: // ignoring REPLACE — default behavior fails if dest exists + case RedisCommand.MOVE: + case RedisCommand.RESTORE: // ignoring REPLACE + case RedisCommand.GETDEL: + case RedisCommand.HGETDEL: + case RedisCommand.HPERSIST: + case RedisCommand.LTRIM: + case RedisCommand.SADD: + case RedisCommand.SREM: + case RedisCommand.SMOVE: + case RedisCommand.ZREM: + case RedisCommand.ZREMRANGEBYRANK: + case RedisCommand.ZREMRANGEBYSCORE: + case RedisCommand.ZREMRANGEBYLEX: + case RedisCommand.HDEL: + case RedisCommand.PFADD: + case RedisCommand.XDEL: + case RedisCommand.XTRIM: + case RedisCommand.XACK: + case RedisCommand.XGROUP: + case RedisCommand.XNACK: + return CommandFlags.CommandRetryWriteChecked; + + // ========================================================================== + // WRITE - LAST WINS — unconditional overwrite of a specific value/state; + // repeating with the same args always converges to the same final value. + // ========================================================================== + case RedisCommand.SET: + case RedisCommand.GETSET: + case RedisCommand.MSET: + case RedisCommand.SETEX: + case RedisCommand.PSETEX: + case RedisCommand.SETRANGE: + case RedisCommand.SETBIT: + case RedisCommand.BITOP: + case RedisCommand.RENAME: + case RedisCommand.EXPIRE: + case RedisCommand.PEXPIRE: + case RedisCommand.EXPIREAT: + case RedisCommand.PEXPIREAT: + case RedisCommand.HSET: + case RedisCommand.HMSET: + case RedisCommand.HEXPIRE: + case RedisCommand.HPEXPIRE: + case RedisCommand.HEXPIREAT: + case RedisCommand.HPEXPIREAT: + case RedisCommand.LSET: + case RedisCommand.ZADD: // ignoring INCR option + case RedisCommand.ZRANGESTORE: + case RedisCommand.ZUNIONSTORE: + case RedisCommand.ZINTERSTORE: + case RedisCommand.ZDIFFSTORE: + case RedisCommand.SDIFFSTORE: + case RedisCommand.SINTERSTORE: + case RedisCommand.SUNIONSTORE: + case RedisCommand.GEOADD: + case RedisCommand.GEOSEARCHSTORE: + case RedisCommand.GEORADIUS: // because of store scenarios + case RedisCommand.GEORADIUSBYMEMBER: + case RedisCommand.XCLAIM: + case RedisCommand.XAUTOCLAIM: + return CommandFlags.CommandRetryWriteLastWins; + + // ========================================================================== + // WRITE - ACCUMULATING — effect compounds with every additional call. + // ========================================================================== + case RedisCommand.INCR: + case RedisCommand.DECR: + case RedisCommand.INCRBY: + case RedisCommand.DECRBY: + case RedisCommand.INCRBYFLOAT: + case RedisCommand.APPEND: + case RedisCommand.HINCRBY: + case RedisCommand.HINCRBYFLOAT: + case RedisCommand.ZINCRBY: + case RedisCommand.LPUSH: + case RedisCommand.RPUSH: + case RedisCommand.LPUSHX: + case RedisCommand.RPUSHX: + case RedisCommand.LINSERT: + case RedisCommand.LREM: + case RedisCommand.LPOP: + case RedisCommand.RPOP: + case RedisCommand.RPOPLPUSH: + case RedisCommand.LMOVE: + case RedisCommand.LMPOP: + case RedisCommand.SPOP: + case RedisCommand.ZPOPMIN: + case RedisCommand.ZPOPMAX: + case RedisCommand.ZMPOP: + case RedisCommand.XADD: + case RedisCommand.PFMERGE: // destination accumulates union each call + return CommandFlags.CommandRetryWriteAccumulating; + + // ========================================================================== + // SERVER ADMIN / NODE-SPECIFIC + // ========================================================================== + case RedisCommand.REPLICAOF: + case RedisCommand.SLAVEOF: + case RedisCommand.BGSAVE: + case RedisCommand.BGREWRITEAOF: + case RedisCommand.SAVE: + case RedisCommand.SHUTDOWN: + case RedisCommand.FLUSHALL: + case RedisCommand.FLUSHDB: + case RedisCommand.SWAPDB: + case RedisCommand.MIGRATE: + case RedisCommand.DEBUG: + case RedisCommand.MONITOR: + case RedisCommand.CONFIG: // note CONFIG GET con be considered more safe + case RedisCommand.SLOWLOG: + case RedisCommand.LATENCY: + case RedisCommand.SCRIPT: + case RedisCommand.CLUSTER: // note: some like MYID can be considered more safe + return CommandFlags.CommandRetryServerAdmin | Message.CommandServerSpecific; + + // ========================================================================== + // NEVER — transactions, arbitrary scripts, and blocking/destructive or + // fire-and-forget commands where a lost ack makes blind retry dangerous. + // ========================================================================== + case RedisCommand.MULTI: + case RedisCommand.EXEC: + case RedisCommand.DISCARD: + case RedisCommand.WATCH: + case RedisCommand.UNWATCH: + case RedisCommand.PUBLISH: + case RedisCommand.SPUBLISH: + case RedisCommand.XREADGROUP: + case RedisCommand.BLPOP: + case RedisCommand.BRPOP: + case RedisCommand.BRPOPLPUSH: + return CommandFlags.CommandRetryNever; + + // ========================================================================== + // scripts / modules / functions; we're going to assume nothing too weird, + // at worst similar to INCR; but it is *hoped* that callers will supply hints. + // ========================================================================== + case RedisCommand.EVAL: + case RedisCommand.EVALSHA: + return CommandFlags.CommandRetryWriteAccumulating; + + // ---- CONNECTION / SESSION ---- + case RedisCommand.ASKING: // cluster ASK redirection flag - per-connection state + case RedisCommand.READONLY: // cluster client read-routing flag - per-connection state + case RedisCommand.READWRITE: // cluster client read-routing flag - per-connection state + case RedisCommand.ROLE: // replication role report, like INFO - no keyspace effect + return CommandFlags.CommandRetryConnection; + + // ---- READ-ONLY ---- + case RedisCommand.ARCOUNT: + case RedisCommand.ARINFO: // structure metadata, like INFO + case RedisCommand.ARGET: + case RedisCommand.ARGETRANGE: + case RedisCommand.ARGREP: + case RedisCommand.ARLASTITEMS: + case RedisCommand.ARLEN: + case RedisCommand.ARMGET: + case RedisCommand.ARSCAN: + case RedisCommand.AROP: + case RedisCommand.DIGEST: // computes a hash of existing data, doesn't mutate it + case RedisCommand.VCARD: + case RedisCommand.VDIM: + case RedisCommand.VEMB: + case RedisCommand.VGETATTR: + case RedisCommand.VINFO: + case RedisCommand.VISMEMBER: + case RedisCommand.VLINKS: + case RedisCommand.VRANDMEMBER: + case RedisCommand.VRANGE: + case RedisCommand.VSIM: + return CommandFlags.CommandRetryReadOnly; + + // ---- WRITE - CHECKED ---- + case RedisCommand.DELEX: // conditional/expiry-aware delete - converges same as DEL + case RedisCommand.VADD: // idempotent add-member, like SADD + case RedisCommand.VREM: // idempotent remove-member, like SREM/ZREM + case RedisCommand.XACKDEL: // ack+delete, converges like XACK/XDEL combined + case RedisCommand.XDELEX: + return CommandFlags.CommandRetryWriteChecked; + + // ---- WRITE - LAST WINS ---- + case RedisCommand.ARDEL: + case RedisCommand.ARDELRANGE: + case RedisCommand.ARMSET: + case RedisCommand.ARSEEK: // repositions a cursor to an explicit point - overwrite semantics + case RedisCommand.ARSET: + case RedisCommand.HSETEX: // HSET + expiry, unconditional overwrite + case RedisCommand.MSETEX: + case RedisCommand.VSETATTR: // unconditional attribute overwrite + case RedisCommand.XCFGSET: + return CommandFlags.CommandRetryWriteLastWins; + + // ---- WRITE - ACCUMULATING ---- + case RedisCommand.ARRING: // presumed create/configure-ring, unconditional define + case RedisCommand.ARINSERT: // ring-buffer insert, compounds like a push + case RedisCommand.ARNEXT: // advances a cursor/position with each call + case RedisCommand.INCREX: // counter semantics + expiry, still accumulating + return CommandFlags.CommandRetryWriteAccumulating; + + // ---- SERVER ADMIN / NODE-SPECIFIC ---- + case RedisCommand.HOTKEYS: // diagnostic/introspection, node-local + case RedisCommand.SENTINEL: + case RedisCommand.SYNC: // replication stream handshake + return CommandFlags.CommandRetryServerAdmin | Message.CommandServerSpecific; + + // if we don't recognize it: default to the most pessimistic + case RedisCommand.NONE: + case RedisCommand.UNKNOWN: + default: + return CommandFlags.CommandRetryNever; + } + } + } +} diff --git a/src/StackExchange.Redis/Enums/CommandFlags.cs b/src/StackExchange.Redis/Enums/CommandFlags.cs index ff69fb750c..510510c6bb 100644 --- a/src/StackExchange.Redis/Enums/CommandFlags.cs +++ b/src/StackExchange.Redis/Enums/CommandFlags.cs @@ -1,5 +1,7 @@ using System; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using RESPite; namespace StackExchange.Redis { @@ -111,5 +113,79 @@ public enum CommandFlags // 2048: Use subscription connection type; never user-specified, so not visible on the public API // 4096: Identifies handshake completion messages; never user-specified, so not visible on the public API + + /* + Command side-effect/retry logic: + The values below reserve a chunk of bits (bits 13-17, i.e. << 13) for command + retry logic/categorization; by default, nothing in this chunk will be passed and + the library will supply a value based on the command being issued. The caller can + override, though - ultimately it is their data/server! They will need to supply a + suitable value for Execute[Async] etc, otherwise we'll assume the worst. + + The math here is intended so that we can specify a numeric "max" that we'll + retry, and can filter with <=, so the higher numbers have more side-effects. + As such, this region is not flags per-se, and we are intentionally leaving gaps + to slide other things in later. Note that 0 is the implicit "not specified" value + (distinct from CommandRetryAlways), and must be resolved to a concrete category + downstream before any <= comparison. CommandServerSpecific (bit 18) is a genuine + single-bit flag, orthogonal to the severity ladder. + */ + + /// + /// The command is always safe to retry, regardless of connection or server state. + /// + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + CommandRetryAlways = 1 << 13, // pre-shift value 1 + + /// + /// The command relates to the connection or to safe metadata (for example CLIENT SETNAME, + /// COMMAND, or CONFIG GET) and can be retried at the connection level. + /// + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + CommandRetryConnection = 4 << 13, // pre-shift value 4 + + /// + /// The command only reads data (for example GET) and can be safely retried. + /// + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + CommandRetryReadOnly = 8 << 13, // pre-shift value 8 + + /// + /// The command writes data conditionally (for example SETNX or SET ... IFEQ), so a retry + /// is checked against server state. + /// + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + CommandRetryWriteChecked = 12 << 13, // pre-shift value 12 + + /// + /// The command writes data such that a retry simply overwrites (last-writer-wins, for example SET). + /// + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + CommandRetryWriteLastWins = 16 << 13, // pre-shift value 16 + + /// + /// The command writes data cumulatively (for example INCR or LPOP), so a retry can + /// double-apply and change the result. + /// + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + CommandRetryWriteAccumulating = 20 << 13, // pre-shift value 20 + + /// + /// The command performs server administration (for example REPLICAOF or CONFIG SET); these + /// are commonly also endpoint-specific (the internal server-specific flag). + /// + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + CommandRetryServerAdmin = 24 << 13, // pre-shift value 24 + + /// + /// The command should never be retried. + /// + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + CommandRetryNever = 31 << 13, // pre-shift value 31 (the full retry-category region) + + // 262144 (bit 18): "server specific" - the command is tied to a specific endpoint and must never be + // retried on a different endpoint (for example cursor-based operations); orthogonal to the retry-category + // region. Internal-only (see Message.CommandServerSpecific): the wrapper database cannot yet express + // endpoint-stickiness over a *range* of operations, so this is not (currently) on the public API. } } diff --git a/src/StackExchange.Redis/ExceptionFactory.cs b/src/StackExchange.Redis/ExceptionFactory.cs index 380ac0c0fe..8428d41af8 100644 --- a/src/StackExchange.Redis/ExceptionFactory.cs +++ b/src/StackExchange.Redis/ExceptionFactory.cs @@ -33,9 +33,9 @@ internal static Exception TooManyArgs(string command, int argCount) internal static Exception CommandHasWhitespace(string command) => new RedisCommandException($"The command '{command}' contains whitespace and would be sent as a single unknown token; pass each word as a separate argument, for example Execute(\"ACL\", \"SETUSER\", \"x\") rather than Execute(\"ACL SETUSER x\")."); - internal static Exception ConnectionFailure(bool includeDetail, ConnectionFailureType failureType, string message, ServerEndPoint? server) + internal static Exception ConnectionFailure(bool includeDetail, ConnectionFailureType failureType, CommandFlags flags, string message, ServerEndPoint? server) { - var ex = new RedisConnectionException(failureType, message); + var ex = new RedisConnectionException(failureType, flags, message); if (includeDetail) AddExceptionDetail(ex, null, server, null); return ex; } @@ -154,7 +154,7 @@ internal static Exception NoConnectionAvailable( data = new List>(); AddCommonDetail(data, sb, message, multiplexer, server); } - var ex = new RedisConnectionException(ConnectionFailureType.UnableToResolvePhysicalConnection, sb.ToString(), innerException, message?.Status ?? CommandStatus.Unknown); + var ex = new RedisConnectionException(ConnectionFailureType.UnableToResolvePhysicalConnection, message?.Flags ?? CommandFlags.None, sb.ToString(), innerException, message?.Status ?? CommandStatus.Unknown); if (multiplexer.RawConfig.IncludeDetailInExceptions) { CopyDataToException(data, ex); @@ -283,12 +283,13 @@ internal static Exception Timeout(ConnectionMultiplexer multiplexer, string? bas // If we're from a backlog timeout scenario, we log a more intuitive connection exception for the timeout...because the timeout was a symptom // and we have a more direct cause: we had no connection to send it on. + var msgFlags = message?.Flags ?? CommandFlags.CommandRetryNever; Exception ex = logConnectionException && lastConnectionException is not null - ? new RedisConnectionException(lastConnectionException.FailureType, sb.ToString(), lastConnectionException, message?.Status ?? CommandStatus.Unknown) + ? new RedisConnectionException(lastConnectionException.FailureType, msgFlags, sb.ToString(), lastConnectionException, message?.Status ?? CommandStatus.Unknown) { HelpLink = TimeoutHelpLink, } - : new RedisTimeoutException(sb.ToString(), message?.Status ?? CommandStatus.Unknown) + : new RedisTimeoutException(msgFlags, sb.ToString(), message?.Status ?? CommandStatus.Unknown) { HelpLink = TimeoutHelpLink, }; @@ -448,7 +449,7 @@ internal static Exception UnableToConnect(ConnectionMultiplexer muxer, string? f sb.Append(' ').Append(failureMessage.Trim()); } - return new RedisConnectionException(failureType, sb.ToString(), inner); + return new RedisConnectionException(failureType, CommandFlags.None, sb.ToString(), inner); } } } diff --git a/src/StackExchange.Redis/Exceptions.cs b/src/StackExchange.Redis/Exceptions.cs index 1f1c973cee..405a0ea4e6 100644 --- a/src/StackExchange.Redis/Exceptions.cs +++ b/src/StackExchange.Redis/Exceptions.cs @@ -25,7 +25,6 @@ public RedisCommandException(string message, Exception innerException) : base(me #if NET8_0_OR_GREATER [Obsolete(Obsoletions.LegacyFormatterImplMessage, DiagnosticId = Obsoletions.LegacyFormatterImplDiagId)] - [EditorBrowsable(EditorBrowsableState.Never)] #endif private RedisCommandException(SerializationInfo info, StreamingContext ctx) : base(info, ctx) { } } @@ -41,8 +40,19 @@ public sealed partial class RedisTimeoutException : TimeoutException /// /// The message for the exception. /// The command status, as of when the timeout happened. - public RedisTimeoutException(string message, CommandStatus commandStatus) : base(message) + [Obsolete("Prefer the overload that specifies CommandFlags")] + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] + public RedisTimeoutException(string message, CommandStatus commandStatus) : this(CommandFlags.CommandRetryNever, message, commandStatus) { } + + /// + /// Creates a new . + /// + /// The command-flags associated with the faulting operation. + /// The message for the exception. + /// The command status, as of when the timeout happened. + public RedisTimeoutException(CommandFlags flags, string message, CommandStatus commandStatus) : base(message) { + Flags = flags; Commandstatus = commandStatus; } @@ -51,9 +61,13 @@ public RedisTimeoutException(string message, CommandStatus commandStatus) : base /// public CommandStatus Commandstatus { get; } + /// + /// The command-flags associated with the faulting operation (including its retry category). + /// + public CommandFlags Flags { get; } + #if NET8_0_OR_GREATER [Obsolete(Obsoletions.LegacyFormatterImplMessage, DiagnosticId = Obsoletions.LegacyFormatterImplDiagId)] - [EditorBrowsable(EditorBrowsableState.Never)] #endif private RedisTimeoutException(SerializationInfo info, StreamingContext ctx) : base(info, ctx) { @@ -67,7 +81,7 @@ private RedisTimeoutException(SerializationInfo info, StreamingContext ctx) : ba /// Serialization context. #if NET8_0_OR_GREATER [Obsolete(Obsoletions.LegacyFormatterImplMessage, DiagnosticId = Obsoletions.LegacyFormatterImplDiagId)] - [EditorBrowsable(EditorBrowsableState.Never)] + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] #endif public override void GetObjectData(SerializationInfo info, StreamingContext context) { @@ -87,7 +101,9 @@ public sealed partial class RedisConnectionException : RedisException /// /// The type of connection failure. /// The message for the exception. - public RedisConnectionException(ConnectionFailureType failureType, string message) : this(failureType, message, null, CommandStatus.Unknown) { } + [Obsolete("Prefer the overload that specifies CommandFlags")] + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] + public RedisConnectionException(ConnectionFailureType failureType, string message) : this(failureType, CommandFlags.CommandRetryNever, message, null, CommandStatus.Unknown) { } /// /// Creates a new . @@ -95,18 +111,33 @@ public RedisConnectionException(ConnectionFailureType failureType, string messag /// The type of connection failure. /// The message for the exception. /// The inner exception. - public RedisConnectionException(ConnectionFailureType failureType, string message, Exception? innerException) : this(failureType, message, innerException, CommandStatus.Unknown) { } + [Obsolete("Prefer the overload that specifies CommandFlags")] + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] + public RedisConnectionException(ConnectionFailureType failureType, string message, Exception? innerException) : this(failureType, CommandFlags.CommandRetryNever, message, innerException, CommandStatus.Unknown) { } + + /// + /// Creates a new . + /// + /// The type of connection failure. + /// The message for the exception. + /// The inner exception. + /// The status of the command. + [Obsolete("Prefer the overload that specifies CommandFlags")] + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] + public RedisConnectionException(ConnectionFailureType failureType, string message, Exception? innerException, CommandStatus commandStatus) : this(failureType, CommandFlags.CommandRetryNever, message, innerException, commandStatus) { } /// /// Creates a new . /// /// The type of connection failure. + /// The command-flags associated with the faulting operation. /// The message for the exception. /// The inner exception. /// The status of the command. - public RedisConnectionException(ConnectionFailureType failureType, string message, Exception? innerException, CommandStatus commandStatus) : base(message, innerException) + public RedisConnectionException(ConnectionFailureType failureType, CommandFlags flags, string message, Exception? innerException = null, CommandStatus commandStatus = CommandStatus.Unknown) : base(message, innerException) { FailureType = failureType; + Flags = flags; CommandStatus = commandStatus; } @@ -115,6 +146,11 @@ public RedisConnectionException(ConnectionFailureType failureType, string messag /// public ConnectionFailureType FailureType { get; } + /// + /// The command-flags associated with the faulting operation (including its retry category). + /// + public CommandFlags Flags { get; } + /// /// Status of the command while communicating with Redis. /// @@ -122,7 +158,6 @@ public RedisConnectionException(ConnectionFailureType failureType, string messag #if NET8_0_OR_GREATER [Obsolete(Obsoletions.LegacyFormatterImplMessage, DiagnosticId = Obsoletions.LegacyFormatterImplDiagId)] - [EditorBrowsable(EditorBrowsableState.Never)] #endif private RedisConnectionException(SerializationInfo info, StreamingContext ctx) : base(info, ctx) { @@ -137,7 +172,7 @@ private RedisConnectionException(SerializationInfo info, StreamingContext ctx) : /// Serialization context. #if NET8_0_OR_GREATER [Obsolete(Obsoletions.LegacyFormatterImplMessage, DiagnosticId = Obsoletions.LegacyFormatterImplDiagId)] - [EditorBrowsable(EditorBrowsableState.Never)] + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] #endif public override void GetObjectData(SerializationInfo info, StreamingContext context) { @@ -173,7 +208,6 @@ public RedisException(string message, Exception? innerException) : base(message, /// Serialization context. #if NET8_0_OR_GREATER [Obsolete(Obsoletions.LegacyFormatterImplMessage, DiagnosticId = Obsoletions.LegacyFormatterImplDiagId)] - [EditorBrowsable(EditorBrowsableState.Never)] #endif protected RedisException(SerializationInfo info, StreamingContext ctx) : base(info, ctx) { } } @@ -188,12 +222,35 @@ public sealed partial class RedisServerException : RedisException /// Creates a new . /// /// The message for the exception. - public RedisServerException(string message) : base(message) { } + [Obsolete("Specify Kind and CommandFlags when possible")] + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] + public RedisServerException(string message) : this(RedisErrorKind.Unknown, CommandFlags.CommandRetryNever, message) { } + + /// + /// Creates a new . + /// + /// The categorized meaning of the error. + /// The command-flags associated with the faulting operation. + /// The message for the exception. + public RedisServerException(RedisErrorKind kind, CommandFlags flags, string message) : base(message) + { + Kind = kind; + Flags = flags; + } #if NET8_0_OR_GREATER [Obsolete(Obsoletions.LegacyFormatterImplMessage, DiagnosticId = Obsoletions.LegacyFormatterImplDiagId)] - [EditorBrowsable(EditorBrowsableState.Never)] #endif private RedisServerException(SerializationInfo info, StreamingContext ctx) : base(info, ctx) { } + + /// + /// Identifies the kind of error received. + /// + public RedisErrorKind Kind { get; } + + /// + /// The command-flags associated with the faulting operation (including its retry category). + /// + public CommandFlags Flags { get; } } } diff --git a/src/StackExchange.Redis/Interfaces/IConnectionMultiplexer.cs b/src/StackExchange.Redis/Interfaces/IConnectionMultiplexer.cs index 96b4ce8f61..f9a4238d23 100644 --- a/src/StackExchange.Redis/Interfaces/IConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/Interfaces/IConnectionMultiplexer.cs @@ -4,6 +4,7 @@ using System.IO; using System.Net; using System.Threading.Tasks; +using StackExchange.Redis.Availability; using StackExchange.Redis.Maintenance; using StackExchange.Redis.Profiling; using static StackExchange.Redis.ConnectionMultiplexer; diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.cs b/src/StackExchange.Redis/Interfaces/IDatabase.cs index c0761b9a75..dbaa70d428 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabase.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs @@ -16,7 +16,7 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// /// The numeric identifier of this database. /// - int Database { get; } + new int Database { get; } /// /// Allows creation of a group of operations that will be sent to the server as a single unit, diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs index 40db55cfdb..b7f43848bd 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs @@ -14,6 +14,9 @@ namespace StackExchange.Redis /// public partial interface IDatabaseAsync : IRedisAsync { + /// + int Database { get; } + /// /// Indicates whether the instance can communicate with the server (resolved using the supplied key and optional flags). /// diff --git a/src/StackExchange.Redis/Interfaces/IInternalDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IInternalDatabaseAsync.cs new file mode 100644 index 0000000000..7ac83afda0 --- /dev/null +++ b/src/StackExchange.Redis/Interfaces/IInternalDatabaseAsync.cs @@ -0,0 +1,64 @@ +using System; +using System.Threading; + +namespace StackExchange.Redis.Interfaces; + +[Flags] +internal enum DatabaseFeatureFlags +{ + None = 0, + Cluster = 1 << 0, + ConnectionGroup = 1 << 1, + Batch = 1 << 2, + Transaction = 1 << 3, + KeyPrefix = 1 << 4, + Retry = 1 << 5, + Unknown = 1 << 6, + Failover = 1 << 7, +} + +internal interface IInternalDatabaseAsync : IDatabaseAsync +{ + DatabaseFeatureFlags GetFeatures(out string name); + CancellationToken GetNextFailover(); +} + +internal static class InternalDatabaseExtension +{ + internal static DatabaseFeatureFlags GetFeatures(this IDatabaseAsync database, out string name) + { + if (database is IInternalDatabaseAsync idb) + { + return idb.GetFeatures(out name); + } + + name = ""; + return DatabaseFeatureFlags.Unknown; + } + + internal static string BuildString(this IDatabaseAsync database) + { + var features = database.GetFeatures(out string name); + return string.IsNullOrEmpty(name) ? features.ToString() : $"{name}: {features}"; + } + + internal static DatabaseFeatureFlags RejectFlags(this IDatabaseAsync database, DatabaseFeatureFlags incompatible) + { + // note: returns *all* the features of the database provided + var features = database.GetFeatures(out _); + var overlap = features & incompatible; + if (overlap is not 0) Throw(overlap); + return features; + + static void Throw(DatabaseFeatureFlags overlap) => throw new InvalidOperationException( + $"This operation is not compatible with feature(s): {overlap}"); + } + + internal static CancellationToken GetNextFailover(this IDatabaseAsync database) + { + // get a CT that represents the next failover; you might be asking "shouldn't that be a Task getter?" - no, + // because Task *does* have ContinueWith, but it doesn't have any mechanism to *undo* that; conversely, + // CancellationToken is expressly designed with that intent, with Register(..) being scoped. + return database is IInternalDatabaseAsync ida ? ida.GetNextFailover() : CancellationToken.None; + } +} diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs index 952fe0ed38..877ffb0b56 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs @@ -3,11 +3,13 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Net; +using System.Threading; using System.Threading.Tasks; +using StackExchange.Redis.Interfaces; namespace StackExchange.Redis.KeyspaceIsolation { - internal partial class KeyPrefixed : IDatabaseAsync where TInner : IDatabaseAsync + internal partial class KeyPrefixed : IDatabaseAsync, IInternalDatabaseAsync where TInner : IDatabaseAsync { internal KeyPrefixed(TInner inner, byte[] keyPrefix) { @@ -17,10 +19,25 @@ internal KeyPrefixed(TInner inner, byte[] keyPrefix) public IConnectionMultiplexer Multiplexer => Inner.Multiplexer; + public int Database => Inner.Database; + internal TInner Inner { get; } internal byte[] Prefix { get; } + DatabaseFeatureFlags IInternalDatabaseAsync.GetFeatures(out string name) + => Inner.GetFeatures(out name) | GetDatabaseFeatures(); + + CancellationToken IInternalDatabaseAsync.GetNextFailover() => Inner.GetNextFailover(); + + // the flags contributed by this wrapper itself (on top of the inner database); the batch and + // transaction subclasses override to fold in their own flag, mirroring RedisDatabase/RedisBatch/ + // RedisTransaction rather than relying on the inner instance to carry it. + private protected virtual DatabaseFeatureFlags GetDatabaseFeatures() + => DatabaseFeatureFlags.KeyPrefix; + + public override string ToString() => this.BuildString(); + public Task DebugObjectAsync(RedisKey key, CommandFlags flags = CommandFlags.None) => Inner.DebugObjectAsync(ToInner(key), flags); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedBatch.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedBatch.cs index 6f5679a66b..a5e07a011e 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedBatch.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedBatch.cs @@ -1,8 +1,16 @@ -namespace StackExchange.Redis.KeyspaceIsolation +using StackExchange.Redis.Interfaces; + +namespace StackExchange.Redis.KeyspaceIsolation { internal sealed class KeyPrefixedBatch : KeyPrefixed, IBatch { - public KeyPrefixedBatch(IBatch inner, byte[] prefix) : base(inner, prefix) { } + public KeyPrefixedBatch(IBatch inner, byte[] prefix) : base(inner, prefix) + { + inner.RejectFlags(DatabaseFeatureFlags.Batch | DatabaseFeatureFlags.Transaction); + } + + private protected override DatabaseFeatureFlags GetDatabaseFeatures() + => base.GetDatabaseFeatures() | DatabaseFeatureFlags.Batch; public void Execute() => Inner.Execute(); } diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index 2ca7ca3844..15deebb89e 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -16,8 +16,6 @@ public IBatch CreateBatch(object? asyncState = null) => public ITransaction CreateTransaction(object? asyncState = null) => new KeyPrefixedTransaction(Inner.CreateTransaction(asyncState), Prefix); - public int Database => Inner.Database; - public RedisValue DebugObject(RedisKey key, CommandFlags flags = CommandFlags.None) => Inner.DebugObject(ToInner(key), flags); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedTransaction.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedTransaction.cs index 89703ba6a0..7b7c6f0ced 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedTransaction.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedTransaction.cs @@ -1,10 +1,17 @@ using System.Threading.Tasks; +using StackExchange.Redis.Interfaces; namespace StackExchange.Redis.KeyspaceIsolation { internal sealed class KeyPrefixedTransaction : KeyPrefixed, ITransaction { - public KeyPrefixedTransaction(ITransaction inner, byte[] prefix) : base(inner, prefix) { } + public KeyPrefixedTransaction(ITransaction inner, byte[] prefix) : base(inner, prefix) + { + inner.RejectFlags(DatabaseFeatureFlags.Batch | DatabaseFeatureFlags.Transaction); + } + + private protected override DatabaseFeatureFlags GetDatabaseFeatures() + => base.GetDatabaseFeatures() | DatabaseFeatureFlags.Transaction; public ConditionResult AddCondition(Condition condition) => Inner.AddCondition(condition.MapKeys(GetMapFunction())); diff --git a/src/StackExchange.Redis/Message.cs b/src/StackExchange.Redis/Message.cs index b37995b78e..b36f3e0829 100644 --- a/src/StackExchange.Redis/Message.cs +++ b/src/StackExchange.Redis/Message.cs @@ -58,7 +58,10 @@ internal abstract partial class Message : ICompletable internal const CommandFlags InternalCallFlag = (CommandFlags)128, - NoFlushFlag = (CommandFlags)1024; + NoFlushFlag = (CommandFlags)1024, + // "server specific" (bit 18): tied to a specific endpoint, never retry elsewhere. Not (yet) a + // public CommandFlags member - see the note on the hidden bit-18 value in CommandFlags.cs. + CommandServerSpecific = (CommandFlags)(1 << 18); protected RedisCommand command; @@ -72,7 +75,12 @@ internal const CommandFlags | CommandFlags.PreferMaster | CommandFlags.PreferReplica; - private const CommandFlags UserSelectableFlags = CommandFlags.None + // the 5-bit retry-category severity region (bits 13-17); numerically equal to CommandRetryNever. + // deliberately excludes CommandServerSpecific (bit 18), which is an orthogonal flag, not part of + // the <=-comparable severity ladder. + internal const CommandFlags MaskRetryCategory = CommandFlags.CommandRetryNever; + + internal const CommandFlags UserSelectableFlags = CommandFlags.None | CommandFlags.DemandMaster | CommandFlags.DemandReplica | CommandFlags.PreferMaster @@ -83,6 +91,8 @@ internal const CommandFlags | CommandFlags.FireAndForget | CommandFlags.NoRedirect | CommandFlags.NoScriptCache + | MaskRetryCategory // caller may override the retry category... + | CommandServerSpecific // ...and the server-specific flag | NoFlushFlag; // we'll allow this one even though not advertised private IResultBox? resultBox; @@ -119,7 +129,9 @@ protected Message(int db, CommandFlags flags, RedisCommand command) bool primaryOnly = command.IsPrimaryOnly(); Db = db; this.command = command; - Flags = flags & UserSelectableFlags; + // apply the user-selectable flags, then fill in the default retry-category for this command + // (WithDefaultCategory is a no-op if the caller already specified a CommandRetry* category) + Flags = (flags & UserSelectableFlags).WithDefaultCategory(command); if (primaryOnly) SetPrimaryOnly(); CreatedDateTime = DateTime.UtcNow; @@ -599,6 +611,12 @@ internal static CommandFlags GetPrimaryReplicaFlags(CommandFlags flags) return flags & MaskPrimaryServerPreference; } + internal static CommandFlags GetRetryCategory(CommandFlags flags) + { + // isolate the retry-category region; 0 here means "not specified" (resolved downstream) + return flags & MaskRetryCategory; + } + internal static bool RequiresDatabase(RedisCommand command) { switch (command) diff --git a/src/StackExchange.Redis/PhysicalBridge.cs b/src/StackExchange.Redis/PhysicalBridge.cs index ca70965bb3..a1ac6e2cf1 100644 --- a/src/StackExchange.Redis/PhysicalBridge.cs +++ b/src/StackExchange.Redis/PhysicalBridge.cs @@ -1354,7 +1354,7 @@ private async ValueTask CompleteWriteAndReleaseLockAsync( private WriteResult HandleWriteException(PhysicalConnection? physical, Message message, Exception ex) { - var inner = new RedisConnectionException(ConnectionFailureType.InternalFailure, "Failed to write", ex); + var inner = new RedisConnectionException(ConnectionFailureType.InternalFailure, message.Flags, "Failed to write", ex); message.SetExceptionAndComplete(inner, physical); // Tear down the physical connection. A write that throws may have left a partial frame on the // wire, and continuing to use the same socket would let the next reply match the wrong message diff --git a/src/StackExchange.Redis/PhysicalConnection.cs b/src/StackExchange.Redis/PhysicalConnection.cs index c4cba03125..ab32d8ae22 100644 --- a/src/StackExchange.Redis/PhysicalConnection.cs +++ b/src/StackExchange.Redis/PhysicalConnection.cs @@ -485,7 +485,7 @@ void AddData(string? lk, string? sk, string? v) AddData("Version", "v", Utils.GetLibVersion()); - outerException = new RedisConnectionException(failureType, exMessage.ToString(), innerException); + outerException = new RedisConnectionException(failureType, CommandFlags.None, exMessage.ToString(), innerException); foreach (var kv in data) { @@ -1108,7 +1108,7 @@ private void OnDebugAbort() var bridge = BridgeCouldBeNull; if (bridge == null || !bridge.Multiplexer.AllowConnect) { - throw new RedisConnectionException(ConnectionFailureType.InternalFailure, "Aborting (AllowConnect: False)"); + throw new RedisConnectionException(ConnectionFailureType.InternalFailure, CommandFlags.None, "Aborting (AllowConnect: False)"); } } @@ -1183,7 +1183,7 @@ public void ObserveMessageResult(Exception? fault) // returns true while healthy); if it trips and we're the *first* to notice, hand off the // teardown rather than doing it inline: RecordConnectionFailed can fail the whole backlog and // build a detailed exception, which we don't want to pay for on the completion thread. - if (circuitBreaker is { } cb && !cb.ObserveResult(fault) + if (circuitBreaker is { } cb && cb.Trip(fault) && Interlocked.CompareExchange(ref _circuitBreakerState, CircuitBreakerTripped, CircuitBreakerHealthy) is CircuitBreakerHealthy) { // hand off to a worker; the heartbeat (see OnBridgeHeartbeat) is a backstop in case the diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 9f2483eb93..47cf16bcf0 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,9 +1,22 @@ #nullable enable +StackExchange.Redis.IDatabaseAsync.Database.get -> int +[SER007]StackExchange.Redis.Availability.RetryPolicy +[SER007]StackExchange.Redis.Availability.RetryPolicy.FailoverDelay.get -> System.TimeSpan +[SER007]StackExchange.Redis.Availability.RetryPolicy.FailoverDelay.set -> void +[SER007]StackExchange.Redis.Availability.RetryPolicy.JitterMax.get -> System.TimeSpan +[SER007]StackExchange.Redis.Availability.RetryPolicy.JitterMax.set -> void +[SER007]StackExchange.Redis.Availability.RetryPolicy.MaxAttempts.get -> int +[SER007]StackExchange.Redis.Availability.RetryPolicy.MaxAttempts.set -> void +[SER007]StackExchange.Redis.Availability.RetryPolicy.MaxAttemptsBeforeFailover.get -> int +[SER007]StackExchange.Redis.Availability.RetryPolicy.MaxAttemptsBeforeFailover.set -> void +[SER007]StackExchange.Redis.Availability.RetryPolicy.MaxCommandRetryCategory.get -> StackExchange.Redis.CommandFlags +[SER007]StackExchange.Redis.Availability.RetryPolicy.MaxCommandRetryCategory.set -> void +[SER007]StackExchange.Redis.Availability.RetryPolicy.RetryDelay.get -> System.TimeSpan +[SER007]StackExchange.Redis.Availability.RetryPolicy.RetryDelay.set -> void +[SER007]StackExchange.Redis.Availability.RetryPolicy.RetryPolicy() -> void StackExchange.Redis.IServer.InventKey(StackExchange.Redis.RedisKey prefix = default(StackExchange.Redis.RedisKey)) -> StackExchange.Redis.RedisKey -[SER007]abstract StackExchange.Redis.Availability.CircuitBreaker.Accumulator.IsHealthy() -> bool -[SER007]abstract StackExchange.Redis.Availability.CircuitBreaker.Accumulator.ObserveResult(in StackExchange.Redis.Availability.CircuitBreaker.CircuitBreakerContext context) -> bool -[SER007]abstract StackExchange.Redis.Availability.CircuitBreaker.Accumulator.Reset() -> void -[SER007]abstract StackExchange.Redis.Availability.CircuitBreaker.CreateAccumulator() -> StackExchange.Redis.Availability.CircuitBreaker.Accumulator! +StackExchange.Redis.Availability.DatabaseExtensions +[SER007]static StackExchange.Redis.Availability.DatabaseExtensions.WithRetry(this StackExchange.Redis.IDatabaseAsync! database, StackExchange.Redis.Availability.RetryPolicy! retryPolicy) -> StackExchange.Redis.IDatabaseAsync! [SER007]StackExchange.Redis.Availability.CircuitBreaker [SER007]StackExchange.Redis.Availability.CircuitBreaker.Accumulator [SER007]StackExchange.Redis.Availability.CircuitBreaker.Accumulator.Accumulator() -> void @@ -16,15 +29,7 @@ StackExchange.Redis.IServer.InventKey(StackExchange.Redis.RedisKey prefix = defa [SER007]StackExchange.Redis.Availability.CircuitBreaker.Builder.MetricsWindowSize.set -> void [SER007]StackExchange.Redis.Availability.CircuitBreaker.Builder.MinimumNumberOfFailures.get -> int [SER007]StackExchange.Redis.Availability.CircuitBreaker.Builder.MinimumNumberOfFailures.set -> void -[SER007]StackExchange.Redis.Availability.CircuitBreaker.Builder.TrackedExceptions.get -> System.Type![]? -[SER007]StackExchange.Redis.Availability.CircuitBreaker.Builder.TrackedExceptions.set -> void [SER007]StackExchange.Redis.Availability.CircuitBreaker.CircuitBreaker() -> void -[SER007]StackExchange.Redis.Availability.CircuitBreaker.CircuitBreakerContext -[SER007]StackExchange.Redis.Availability.CircuitBreaker.CircuitBreakerContext.CircuitBreakerContext() -> void -[SER007]StackExchange.Redis.Availability.CircuitBreaker.CircuitBreakerContext.CircuitBreakerContext(System.Exception? fault, bool evaluate = true) -> void -[SER007]StackExchange.Redis.Availability.CircuitBreaker.CircuitBreakerContext.Fault.get -> System.Exception? -[SER007]StackExchange.Redis.Availability.CircuitBreaker.CircuitBreakerContext.Success.get -> bool -[SER007]static StackExchange.Redis.Availability.CircuitBreaker.Builder.implicit operator StackExchange.Redis.Availability.CircuitBreaker!(StackExchange.Redis.Availability.CircuitBreaker.Builder! builder) -> StackExchange.Redis.Availability.CircuitBreaker! [SER007]StackExchange.Redis.Availability.ConnectionGroupMember [SER007]StackExchange.Redis.Availability.ConnectionGroupMember.ConnectionGroupMember(StackExchange.Redis.ConfigurationOptions! configuration, string! name = "") -> void [SER007]StackExchange.Redis.Availability.ConnectionGroupMember.ConnectionGroupMember(string! configuration, string! name = "") -> void @@ -97,31 +102,92 @@ StackExchange.Redis.IServer.InventKey(StackExchange.Redis.RedisKey prefix = defa [SER007]StackExchange.Redis.Availability.MultiGroupOptions.MultiGroupOptions() -> void [SER007]static StackExchange.Redis.ConnectionMultiplexer.ConnectGroupAsync(StackExchange.Redis.Availability.ConnectionGroupMember! member0, StackExchange.Redis.Availability.ConnectionGroupMember! member1, StackExchange.Redis.Availability.MultiGroupOptions? options = null, System.IO.TextWriter? log = null) -> System.Threading.Tasks.Task! [SER007]static StackExchange.Redis.ConnectionMultiplexer.ConnectGroupAsync(StackExchange.Redis.Availability.ConnectionGroupMember![]! members, StackExchange.Redis.Availability.MultiGroupOptions? options = null, System.IO.TextWriter? log = null) -> System.Threading.Tasks.Task! +[SER007]StackExchange.Redis.ConfigurationOptions.CircuitBreaker.get -> StackExchange.Redis.Availability.CircuitBreaker? +[SER007]StackExchange.Redis.ConfigurationOptions.CircuitBreaker.set -> void +[SER007]StackExchange.Redis.ConfigurationOptions.HealthCheck.get -> StackExchange.Redis.Availability.HealthCheck? +[SER007]StackExchange.Redis.ConfigurationOptions.HealthCheck.set -> void +[SER007]StackExchange.Redis.ConnectionFailureType.CircuitBreaker = 11 -> StackExchange.Redis.ConnectionFailureType +[SER007]StackExchange.Redis.Availability.ConnectionGroupMember.IsUnhealthy.get -> bool +[SER007]StackExchange.Redis.Availability.ConnectionGroupMember.ResetIsUnhealthy() -> void +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.FailbackDelay.get -> System.TimeSpan +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.FailbackDelay.set -> void +[SER007]abstract StackExchange.Redis.Availability.CircuitBreaker.Accumulator.IsHealthy() -> bool +[SER007]abstract StackExchange.Redis.Availability.CircuitBreaker.Accumulator.Reset() -> void +[SER007]abstract StackExchange.Redis.Availability.CircuitBreaker.CreateAccumulator() -> StackExchange.Redis.Availability.CircuitBreaker.Accumulator! [SER007]static StackExchange.Redis.Availability.CircuitBreaker.Default.get -> StackExchange.Redis.Availability.CircuitBreaker! [SER007]static StackExchange.Redis.Availability.CircuitBreaker.None.get -> StackExchange.Redis.Availability.CircuitBreaker! +[SER007]static StackExchange.Redis.Availability.CircuitBreaker.Builder.implicit operator StackExchange.Redis.Availability.CircuitBreaker!(StackExchange.Redis.Availability.CircuitBreaker.Builder! builder) -> StackExchange.Redis.Availability.CircuitBreaker! [SER007]abstract StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.CheckHealthAsync(StackExchange.Redis.Availability.HealthCheck! healthCheck, StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! -[SER007]abstract StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy.Evaluate(in StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext context) -> StackExchange.Redis.Availability.HealthCheck.HealthCheckResult -[SER007]abstract StackExchange.Redis.Availability.HealthCheck.KeyWriteHealthCheckProbe.CheckHealthAsync(StackExchange.Redis.Availability.HealthCheck! healthCheck, StackExchange.Redis.IDatabaseAsync! database, StackExchange.Redis.RedisKey key) -> System.Threading.Tasks.Task! -[SER007]override StackExchange.Redis.Availability.ConnectionGroupMember.ToString() -> string! -[SER007]override StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext.ToString() -> string! -[SER007]override StackExchange.Redis.Availability.HealthCheck.KeyWriteHealthCheckProbe.CheckHealthAsync(StackExchange.Redis.Availability.HealthCheck! healthCheck, StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! -[SER007]static StackExchange.Redis.Availability.HealthCheck.Default.get -> StackExchange.Redis.Availability.HealthCheck! [SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.HealthyTask.get -> System.Threading.Tasks.Task! [SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.InconclusiveTask.get -> System.Threading.Tasks.Task! +[SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.UnhealthyTask.get -> System.Threading.Tasks.Task! [SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.IsConnected.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe! [SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.Ping.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe! [SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.StringSet.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe! -[SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.UnhealthyTask.get -> System.Threading.Tasks.Task! +[SER007]abstract StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy.Evaluate(in StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext context) -> StackExchange.Redis.Availability.HealthCheck.HealthCheckResult [SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy.AllSuccess.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy! [SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy.AnySuccess.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy! [SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy.MajoritySuccess.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy! +[SER007]abstract StackExchange.Redis.Availability.HealthCheck.KeyWriteHealthCheckProbe.CheckHealthAsync(StackExchange.Redis.Availability.HealthCheck! healthCheck, StackExchange.Redis.IDatabaseAsync! database, StackExchange.Redis.RedisKey key) -> System.Threading.Tasks.Task! +[SER007]override StackExchange.Redis.Availability.HealthCheck.KeyWriteHealthCheckProbe.CheckHealthAsync(StackExchange.Redis.Availability.HealthCheck! healthCheck, StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! +[SER007]override StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext.ToString() -> string! +[SER007]static StackExchange.Redis.Availability.HealthCheck.Default.get -> StackExchange.Redis.Availability.HealthCheck! [SER007]static StackExchange.Redis.Availability.MultiGroupOptions.Default.get -> StackExchange.Redis.Availability.MultiGroupOptions! -[SER007]StackExchange.Redis.ConfigurationOptions.CircuitBreaker.get -> StackExchange.Redis.Availability.CircuitBreaker? -[SER007]StackExchange.Redis.ConfigurationOptions.CircuitBreaker.set -> void -[SER007]StackExchange.Redis.ConfigurationOptions.HealthCheck.get -> StackExchange.Redis.Availability.HealthCheck? -[SER007]StackExchange.Redis.ConfigurationOptions.HealthCheck.set -> void -[SER007]StackExchange.Redis.ConnectionFailureType.CircuitBreaker = 11 -> StackExchange.Redis.ConnectionFailureType -[SER007]StackExchange.Redis.Availability.ConnectionGroupMember.IsUnhealthy.get -> bool -[SER007]StackExchange.Redis.Availability.ConnectionGroupMember.ResetIsUnhealthy() -> void -[SER007]StackExchange.Redis.Availability.MultiGroupOptions.FailbackDelay.get -> System.TimeSpan -[SER007]StackExchange.Redis.Availability.MultiGroupOptions.FailbackDelay.set -> void +[SER007]override StackExchange.Redis.Availability.ConnectionGroupMember.ToString() -> string! +[SER007]StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.None = 0 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.Unknown = 1 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.Availability.FaultContext +[SER007]StackExchange.Redis.Availability.FaultContext.ConnectionFailureType.get -> StackExchange.Redis.ConnectionFailureType +[SER007]StackExchange.Redis.Availability.FaultContext.ErrorKind.get -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.Availability.FaultContext.Fault.get -> System.Exception? +[SER007]StackExchange.Redis.Availability.FaultContext.FaultContext() -> void +[SER007]StackExchange.Redis.Availability.FaultContext.FaultContext(System.Exception! fault) -> void +[SER007]StackExchange.Redis.Availability.FaultContext.Flags.get -> StackExchange.Redis.CommandFlags +[SER007]StackExchange.Redis.Availability.FaultContext.IsFault.get -> bool +[SER007]StackExchange.Redis.RedisErrorKind.Ask = 15 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.Busy = 12 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.ClusterDown = 6 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.ConnectionFault = 3 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.CrossSlot = 16 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.ExecAbort = 23 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.Loading = 5 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.MasterDown = 7 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.MaxClients = 13 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.Misconfigured = 10 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.Moved = 14 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.NoAuth = 17 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.NoPermission = 19 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.NoReplicas = 9 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.NoScript = 25 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.NotPermitted = 20 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.OutOfMemory = 11 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.ReadOnly = 24 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.Timeout = 4 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.TryAgain = 8 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.UnknownCommand = 21 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.UnknownError = 2 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.WrongPass = 18 -> StackExchange.Redis.RedisErrorKind +[SER007]StackExchange.Redis.RedisErrorKind.WrongType = 22 -> StackExchange.Redis.RedisErrorKind +[SER007]abstract StackExchange.Redis.Availability.CircuitBreaker.Accumulator.ObserveResult(in StackExchange.Redis.Availability.FaultContext fault) -> void +[SER007]virtual StackExchange.Redis.Availability.CircuitBreaker.Accumulator.IsFailure(in StackExchange.Redis.Availability.FaultContext fault) -> bool +[SER007]virtual StackExchange.Redis.Availability.RetryPolicy.CanRetry(in StackExchange.Redis.Availability.FaultContext fault) -> StackExchange.Redis.Availability.RetryPolicy.RetryPolicyResult +[SER007]StackExchange.Redis.Availability.RetryPolicy.RetryPolicyResult +[SER007]StackExchange.Redis.Availability.RetryPolicy.RetryPolicyResult.None = 0 -> StackExchange.Redis.Availability.RetryPolicy.RetryPolicyResult +[SER007]StackExchange.Redis.Availability.RetryPolicy.RetryPolicyResult.SameServer = 1 -> StackExchange.Redis.Availability.RetryPolicy.RetryPolicyResult +[SER007]StackExchange.Redis.Availability.RetryPolicy.RetryPolicyResult.FailoverServer = 2 -> StackExchange.Redis.Availability.RetryPolicy.RetryPolicyResult +[SER007]StackExchange.Redis.CommandFlags.CommandRetryAlways = 8192 -> StackExchange.Redis.CommandFlags +[SER007]StackExchange.Redis.CommandFlags.CommandRetryConnection = 32768 -> StackExchange.Redis.CommandFlags +[SER007]StackExchange.Redis.CommandFlags.CommandRetryReadOnly = 65536 -> StackExchange.Redis.CommandFlags +[SER007]StackExchange.Redis.CommandFlags.CommandRetryWriteChecked = StackExchange.Redis.CommandFlags.CommandRetryConnection | StackExchange.Redis.CommandFlags.CommandRetryReadOnly -> StackExchange.Redis.CommandFlags +[SER007]StackExchange.Redis.CommandFlags.CommandRetryWriteLastWins = 131072 -> StackExchange.Redis.CommandFlags +[SER007]StackExchange.Redis.CommandFlags.CommandRetryWriteAccumulating = StackExchange.Redis.CommandFlags.CommandRetryConnection | StackExchange.Redis.CommandFlags.CommandRetryWriteLastWins -> StackExchange.Redis.CommandFlags +[SER007]StackExchange.Redis.CommandFlags.CommandRetryServerAdmin = StackExchange.Redis.CommandFlags.CommandRetryReadOnly | StackExchange.Redis.CommandFlags.CommandRetryWriteLastWins -> StackExchange.Redis.CommandFlags +[SER007]StackExchange.Redis.CommandFlags.CommandRetryNever = 253952 -> StackExchange.Redis.CommandFlags +StackExchange.Redis.RedisConnectionException.Flags.get -> StackExchange.Redis.CommandFlags +StackExchange.Redis.RedisConnectionException.RedisConnectionException(StackExchange.Redis.ConnectionFailureType failureType, StackExchange.Redis.CommandFlags flags, string! message, System.Exception? innerException = null, StackExchange.Redis.CommandStatus commandStatus = StackExchange.Redis.CommandStatus.Unknown) -> void +StackExchange.Redis.RedisServerException.Flags.get -> StackExchange.Redis.CommandFlags +StackExchange.Redis.RedisServerException.Kind.get -> StackExchange.Redis.RedisErrorKind +StackExchange.Redis.RedisServerException.RedisServerException(StackExchange.Redis.RedisErrorKind kind, StackExchange.Redis.CommandFlags flags, string! message) -> void +StackExchange.Redis.RedisTimeoutException.Flags.get -> StackExchange.Redis.CommandFlags +StackExchange.Redis.RedisTimeoutException.RedisTimeoutException(StackExchange.Redis.CommandFlags flags, string! message, StackExchange.Redis.CommandStatus commandStatus) -> void diff --git a/src/StackExchange.Redis/RedisBatch.cs b/src/StackExchange.Redis/RedisBatch.cs index c827ba1dce..5b9e527eae 100644 --- a/src/StackExchange.Redis/RedisBatch.cs +++ b/src/StackExchange.Redis/RedisBatch.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using StackExchange.Redis.Interfaces; namespace StackExchange.Redis { @@ -8,7 +9,14 @@ internal sealed class RedisBatch : RedisDatabase, IBatch { private List? pending; - public RedisBatch(RedisDatabase wrapped, object? asyncState) : base(wrapped.multiplexer, wrapped.Database, asyncState ?? wrapped.AsyncState) { } + public RedisBatch(RedisDatabase wrapped, object? asyncState) + : base(wrapped.multiplexer, wrapped.Database, asyncState ?? wrapped.AsyncState) + { + wrapped.RejectFlags(DatabaseFeatureFlags.Batch | DatabaseFeatureFlags.Transaction); + } + + private protected override DatabaseFeatureFlags GetDatabaseFeatures() + => base.GetDatabaseFeatures() | DatabaseFeatureFlags.Batch; public void Execute() { diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index 21518eaa7d..aadaf5abe6 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -4,12 +4,14 @@ using System.Diagnostics; using System.Net; using System.Runtime.CompilerServices; +using System.Threading; using System.Threading.Tasks; using RESPite.Messages; +using StackExchange.Redis.Interfaces; namespace StackExchange.Redis { - internal partial class RedisDatabase : RedisBase, IDatabase + internal partial class RedisDatabase : RedisBase, IDatabase, IInternalDatabaseAsync { internal RedisDatabase(ConnectionMultiplexer multiplexer, int db, object? asyncState) : base(multiplexer, asyncState) @@ -21,6 +23,20 @@ internal RedisDatabase(ConnectionMultiplexer multiplexer, int db, object? asyncS public int Database { get; } + DatabaseFeatureFlags IInternalDatabaseAsync.GetFeatures(out string name) + { + name = multiplexer.ClientName; + return GetDatabaseFeatures(); + } + + CancellationToken IInternalDatabaseAsync.GetNextFailover() => CancellationToken.None; + + // the base feature set for this database; wrapping databases (batch, transaction, ...) override + // to fold in their own flag. Cluster is intrinsic to the underlying connection, so it lives here. + private protected virtual DatabaseFeatureFlags GetDatabaseFeatures() + => multiplexer.ServerSelectionStrategy.ServerType == ServerType.Cluster + ? DatabaseFeatureFlags.Cluster : DatabaseFeatureFlags.None; + public IBatch CreateBatch(object? asyncState) { if (this is IBatch) throw new NotSupportedException("Nested batches are not supported"); diff --git a/src/StackExchange.Redis/RedisErrorKind.cs b/src/StackExchange.Redis/RedisErrorKind.cs new file mode 100644 index 0000000000..4fdebf33fd --- /dev/null +++ b/src/StackExchange.Redis/RedisErrorKind.cs @@ -0,0 +1,235 @@ +using System; +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using RESPite; +using RESPite.Messages; + +namespace StackExchange.Redis; + +/// +/// Well-known server error conditions, identified from the error-reply prefix (and in some cases the +/// message text). Used to classify faults consistently - in particular to decide whether a fault is +/// transient (worth retrying / awaiting failover) or permanent (retrying will never help). +/// +[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +public enum RedisErrorKind +{ + /// + /// No error condition; the reply was not an error. + /// + [AsciiHash("")] + None = 0, + + /// + /// The error was not recognized as one of the well-known conditions and did not start with ERR. + /// + [AsciiHash("")] + Unknown, + + /// + /// The error was not recognized as one of the well-known conditions, but started with ERR. + /// + [AsciiHash("")] + UnknownError, + + /// + /// The error was due to a connection fault, categorized separately by . + /// + [AsciiHash("")] + ConnectionFault, + + /// + /// The operation timed out; this is a client-side condition and does not correspond to a server reply. + /// + [AsciiHash("")] + Timeout, + + // --- availability / typically transient (retry or failover may recover) --- + + /// + /// LOADING - the server is still loading its dataset into memory and is not yet ready. + /// + Loading, + + /// + /// CLUSTERDOWN - the cluster is down; the hash slot is not currently being served. + /// + ClusterDown, + + /// + /// MASTERDOWN - the link with the primary is down and replica-serve-stale-data is no. + /// + MasterDown, + + /// + /// TRYAGAIN - a multi-key operation spans slots that are being migrated; the client should retry. + /// + TryAgain, + + /// + /// NOREPLICAS - not enough healthy replicas to satisfy the configured min-replicas-to-write. + /// + NoReplicas, + + /// + /// MISCONF - RDB/AOF persistence is misconfigured and writes are currently refused. + /// + [AsciiHash("MISCONF")] + Misconfigured, + + /// + /// OOM - the command was refused because used memory is above the maxmemory limit. + /// + [AsciiHash("OOM")] + OutOfMemory, + + /// + /// BUSY - a script or function is running and blocking the server (needs SCRIPT KILL etc.). + /// + Busy, + + /// + /// MAXCLIENTS - the configured maximum number of client connections has been reached. + /// + MaxClients, + + // --- cluster slot routing --- + + /// + /// MOVED - the hash slot has permanently moved to another endpoint. + /// + Moved, + + /// + /// ASK - the hash slot is temporarily served by another endpoint during migration. + /// + Ask, + + /// + /// CROSSSLOT - the keys in a multi-key operation do not all hash to the same slot. + /// + CrossSlot, + + // --- authentication / authorization (will not recover without a credential or ACL change) --- + + /// + /// NOAUTH - authentication is required before commands can be issued. + /// + NoAuth, + + /// + /// WRONGPASS - the supplied username/password pair was rejected. + /// + WrongPass, + + /// + /// NOPERM - the authenticated ACL user is not permitted to run this command/key/channel. + /// + [AsciiHash("NOPERM")] + NoPermission, + + /// + /// ERR operation not permitted - the operation is not permitted in the current context. + /// + [AsciiHash("")] // matched via the "ERR " branch, not the first token + NotPermitted, + + // --- client / usage errors (permanent - retry and failover will never help) --- + + /// + /// ERR unknown command - the command is not known to the server (typo, disabled, or unsupported version). + /// + [AsciiHash("ERR")] // matched via the "ERR " branch, not the first token + UnknownCommand, + + /// + /// WRONGTYPE - the operation was applied against a key holding an incompatible value type. + /// + WrongType, + + /// + /// EXECABORT - the transaction was discarded because of an earlier error while queuing commands. + /// + ExecAbort, + + /// + /// READONLY - a write was attempted against a read-only replica. + /// + ReadOnly, + + /// + /// NOSCRIPT - no matching script for EVALSHA; the script must be re-loaded. + /// + NoScript, +} + +internal static partial class RedisErrorKindMetadata +{ + [AsciiHash(CaseSensitive = false)] + private static partial bool TryParseFirstTokenCI(ReadOnlySpan value, out RedisErrorKind kind); + + /// + /// Classifies the error currently held by . The caller is assumed to have + /// already established that this is an error, so an unrecognized reply yields + /// (never ). + /// + internal static unsafe RedisErrorKind Classify(in RespReader reader) + => reader.TryParseScalar(&TryParse, out RedisErrorKind kind) ? kind : RedisErrorKind.Unknown; + + internal static unsafe RedisErrorKind Classify(string message) + => RespReader.TryParseScalar(message.AsSpan(), &TryParse, out RedisErrorKind kind) ? kind : RedisErrorKind.Unknown; + + internal static bool TryParse(ReadOnlySpan value, out RedisErrorKind kind) + { + var space = value.IndexOf((byte)' '); + // Deal with the exact matches on the first token first - many errors may or may not + // have descriptive text after the leading token. + var firstToken = space > 0 ? value.Slice(0, space) : value; + if (TryParseFirstTokenCI(firstToken, out kind)) + { + // check for more specific "ERR ..." scenarios + if (kind is RedisErrorKind.UnknownError & space > 0) + { + // get the message text after "ERR " + value = value.Slice(space + 1); + + // some ERR conditions can be identified further, noting that the text may or + // may not have some tokens - sometimes we need partial match. + var valueHash = AsciiHash.HashUC(value); + if (value.Length is OperationNotPermitted.Length && OperationNotPermitted.IsCI(value, valueHash)) + { + kind = RedisErrorKind.NotPermitted; + } + else if (value.Length >= UnknownCommand.Length && + AsciiHash.SequenceEqualsCI(value.Slice(0, UnknownCommand.Length), UnknownCommand.U8)) + { + kind = RedisErrorKind.UnknownCommand; + } + } + return true; + } + + kind = value.IsEmpty ? RedisErrorKind.None : RedisErrorKind.Unknown; + return true; + } + + [AsciiHash("operation not permitted")] + private static partial class OperationNotPermitted { } + + [AsciiHash("unknown command")] + private static partial class UnknownCommand { } + + /* while these are recognizable, we issue SELECT *on behalf* of the user + and react internally, so reporting them seems... unnecessary. + + [AsciiHash("DB index is out of range")] + private static partial class DbIndexOutOfRange { } + + [AsciiHash("invalid DB index")] + private static partial class InvalidDbIndex { } + + [AsciiHash("SELECT is not allowed in cluster mode")] + private static partial class SelectNotAllowedInClusterMode { } + */ +} diff --git a/src/StackExchange.Redis/RedisResult.cs b/src/StackExchange.Redis/RedisResult.cs index 78ed649bcb..1d496468fd 100644 --- a/src/StackExchange.Redis/RedisResult.cs +++ b/src/StackExchange.Redis/RedisResult.cs @@ -558,7 +558,10 @@ internal override RedisValue AsRedisValue() private sealed class ErrorRedisResult : RedisResult { private readonly string value; + private RedisErrorKind kind; // lazily parsed from the value + private RedisErrorKind Kind => kind is RedisErrorKind.None ? GetKind() : kind; + private RedisErrorKind GetKind() => kind = RedisErrorKindMetadata.Classify(value); public ErrorRedisResult(string? value, ResultType type) : base(type) { this.value = value ?? throw new ArgumentNullException(nameof(value)); @@ -570,30 +573,30 @@ public ErrorRedisResult(string? value, ResultType type) : base(type) type = null; return value; } - internal override bool AsBoolean() => throw new RedisServerException(value); - internal override bool[] AsBooleanArray() => throw new RedisServerException(value); - internal override byte[] AsByteArray() => throw new RedisServerException(value); - internal override byte[][] AsByteArrayArray() => throw new RedisServerException(value); - internal override double AsDouble() => throw new RedisServerException(value); - internal override double[] AsDoubleArray() => throw new RedisServerException(value); - internal override int AsInt32() => throw new RedisServerException(value); - internal override int[] AsInt32Array() => throw new RedisServerException(value); - internal override long AsInt64() => throw new RedisServerException(value); - internal override ulong AsUInt64() => throw new RedisServerException(value); - internal override long[] AsInt64Array() => throw new RedisServerException(value); - internal override ulong[] AsUInt64Array() => throw new RedisServerException(value); - internal override bool? AsNullableBoolean() => throw new RedisServerException(value); - internal override double? AsNullableDouble() => throw new RedisServerException(value); - internal override int? AsNullableInt32() => throw new RedisServerException(value); - internal override long? AsNullableInt64() => throw new RedisServerException(value); - internal override ulong? AsNullableUInt64() => throw new RedisServerException(value); - internal override RedisKey AsRedisKey() => throw new RedisServerException(value); - internal override RedisKey[] AsRedisKeyArray() => throw new RedisServerException(value); - internal override RedisResult[] AsRedisResultArray() => throw new RedisServerException(value); - internal override RedisValue AsRedisValue() => throw new RedisServerException(value); - internal override RedisValue[] AsRedisValueArray() => throw new RedisServerException(value); - internal override string? AsString() => throw new RedisServerException(value); - internal override string?[]? AsStringArray() => throw new RedisServerException(value); + internal override bool AsBoolean() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override bool[] AsBooleanArray() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override byte[] AsByteArray() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override byte[][] AsByteArrayArray() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override double AsDouble() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override double[] AsDoubleArray() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override int AsInt32() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override int[] AsInt32Array() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override long AsInt64() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override ulong AsUInt64() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override long[] AsInt64Array() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override ulong[] AsUInt64Array() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override bool? AsNullableBoolean() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override double? AsNullableDouble() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override int? AsNullableInt32() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override long? AsNullableInt64() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override ulong? AsNullableUInt64() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override RedisKey AsRedisKey() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override RedisKey[] AsRedisKeyArray() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override RedisResult[] AsRedisResultArray() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override RedisValue AsRedisValue() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override RedisValue[] AsRedisValueArray() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override string? AsString() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); + internal override string?[]? AsStringArray() => throw new RedisServerException(Kind, CommandFlags.CommandRetryNever, value); } private sealed class SingleRedisResult : RedisResult, IConvertible diff --git a/src/StackExchange.Redis/RedisTransaction.cs b/src/StackExchange.Redis/RedisTransaction.cs index 5d54306710..c1c39b3c1a 100644 --- a/src/StackExchange.Redis/RedisTransaction.cs +++ b/src/StackExchange.Redis/RedisTransaction.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using RESPite.Messages; +using StackExchange.Redis.Interfaces; namespace StackExchange.Redis { @@ -14,8 +15,12 @@ internal sealed class RedisTransaction : RedisDatabase, ITransaction private List? _pending; private object SyncLock => this; + private protected override DatabaseFeatureFlags GetDatabaseFeatures() + => base.GetDatabaseFeatures() | DatabaseFeatureFlags.Transaction; + public RedisTransaction(RedisDatabase wrapped, object? asyncState) : base(wrapped.multiplexer, wrapped.Database, asyncState ?? wrapped.AsyncState) { + wrapped.RejectFlags(DatabaseFeatureFlags.Batch | DatabaseFeatureFlags.Transaction); // need to check we can reliably do this... var commandMap = multiplexer.CommandMap; commandMap.AssertAvailable(RedisCommand.MULTI); @@ -480,11 +485,12 @@ public override bool SetResult(PhysicalConnection connection, Message message, r reader.MovePastBof(); if (reader.IsError && message is TransactionMessage tran) { + var errorKind = RedisErrorKindMetadata.Classify(reader); string error = reader.ReadString()!; foreach (var op in tran.InnerOperations) { var inner = op.Wrapped; - ServerFail(inner, error); + ServerFail(inner, errorKind, error); inner.Complete(connection); } } diff --git a/src/StackExchange.Redis/ResultProcessor.Literals.cs b/src/StackExchange.Redis/ResultProcessor.Literals.cs index 7d50cc09e6..3deaa7027c 100644 --- a/src/StackExchange.Redis/ResultProcessor.Literals.cs +++ b/src/StackExchange.Redis/ResultProcessor.Literals.cs @@ -8,16 +8,6 @@ internal partial class Literals { #pragma warning disable CS8981, SA1300, SA1134 // forgive naming etc // ReSharper disable InconsistentNaming - [AsciiHash] internal static partial class NOAUTH { } - [AsciiHash] internal static partial class WRONGPASS { } - [AsciiHash] internal static partial class NOSCRIPT { } - [AsciiHash] internal static partial class MOVED { } - [AsciiHash] internal static partial class ASK { } - [AsciiHash] internal static partial class READONLY { } - [AsciiHash] internal static partial class LOADING { } - [AsciiHash("ERR operation not permitted")] - internal static partial class ERR_not_permitted { } - // Result processor literals [AsciiHash] internal static partial class OK diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index c4ad5e801a..45a09cfbc2 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -210,15 +210,15 @@ public void ConnectionFail(Message message, ConnectionFailureType fail, Exceptio sb.Append(", "); sb.Append(annotation); } - var ex = new RedisConnectionException(fail, sb.ToString(), innerException); + var ex = new RedisConnectionException(fail, message?.Flags ?? CommandFlags.None, sb.ToString(), innerException); SetException(message, ex); } public static void ConnectionFail(Message message, ConnectionFailureType fail, string errorMessage) => - SetException(message, new RedisConnectionException(fail, errorMessage)); + SetException(message, new RedisConnectionException(fail, message.Flags, errorMessage)); - public static void ServerFail(Message message, string errorMessage) => - SetException(message, new RedisServerException(errorMessage)); + public static void ServerFail(Message message, RedisErrorKind kind, string errorMessage) => + SetException(message, new RedisServerException(kind, message.Flags, errorMessage)); public static void SetException(Message? message, Exception ex) { @@ -263,22 +263,24 @@ private bool HandleCommonError(Message message, RespReader reader, PhysicalConne { connection.OnDetailLog($"applying common error-handling: {reader.GetOverview()}"); var bridge = connection.BridgeCouldBeNull; - if (reader.StartsWith(Literals.NOAUTH.U8)) + var errorKind = RedisErrorKindMetadata.Classify(reader); + switch (errorKind) { - bridge?.Multiplexer.SetAuthSuspect(new RedisServerException("NOAUTH Returned - connection has not yet authenticated")); - } - else if (reader.StartsWith(Literals.WRONGPASS.U8)) - { - bridge?.Multiplexer.SetAuthSuspect(new RedisServerException(reader.GetOverview())); + case RedisErrorKind.NoAuth: + bridge?.Multiplexer.SetAuthSuspect(new RedisServerException(errorKind, message.Flags, "NOAUTH Returned - connection has not yet authenticated")); + break; + case RedisErrorKind.WrongPass: + bridge?.Multiplexer.SetAuthSuspect(new RedisServerException(errorKind, message.Flags, reader.GetOverview())); + break; } var server = bridge?.ServerEndPoint; bool log = !message.IsInternalCall; - bool isMoved = reader.StartsWith(Literals.MOVED.U8); + bool isMoved = errorKind == RedisErrorKind.Moved; bool wasNoRedirect = (message.Flags & CommandFlags.NoRedirect) != 0; string? err = string.Empty; bool unableToConnectError = false; - if (isMoved || reader.StartsWith(Literals.ASK.U8)) + if (isMoved || errorKind == RedisErrorKind.Ask) { connection.OnDetailLog($"redirect via {(isMoved ? "MOVED" : "ASK")} to '{reader.ReadString()}'"); message.SetResponseReceived(); @@ -361,7 +363,7 @@ private bool HandleCommonError(Message message, RespReader reader, PhysicalConne } else { - ServerFail(message, err); + ServerFail(message, errorKind, err); } return true; @@ -852,7 +854,7 @@ public override bool SetResult(PhysicalConnection connection, Message message, r { var copy = reader; reader.MovePastBof(); - if (reader.IsError && reader.StartsWith(Literals.READONLY.U8)) + if (reader.IsError && RedisErrorKindMetadata.Classify(reader) == RedisErrorKind.ReadOnly) { var bridge = connection.BridgeCouldBeNull; if (bridge != null) @@ -2249,7 +2251,7 @@ public override bool SetResult(PhysicalConnection connection, Message message, r { var copy = reader; reader.MovePastBof(); - if (reader.IsError && reader.StartsWith(Literals.NOSCRIPT.U8)) + if (reader.IsError && RedisErrorKindMetadata.Classify(reader) == RedisErrorKind.NoScript) { // scripts are not flushed individually, so assume the entire script cache is toast ("SCRIPT FLUSH") connection.BridgeCouldBeNull?.ServerEndPoint?.FlushScriptCache(); message.SetScriptUnavailable(); @@ -3167,17 +3169,18 @@ public override bool SetResult(PhysicalConnection connection, Message message, r if (isError) { reader = copy; // rewind and re-parse - if (reader.StartsWith(Literals.ERR_not_permitted.U8) || reader.StartsWith(Literals.NOAUTH.U8)) + var errorKind = RedisErrorKindMetadata.Classify(reader); + if (errorKind is RedisErrorKind.NotPermitted or RedisErrorKind.NoAuth) { connection.RecordConnectionFailed(ConnectionFailureType.AuthenticationFailure, new Exception(reader.GetOverview() + " Verify if the Redis password provided is correct. Attempted command: " + message.Command)); } - else if (reader.StartsWith(Literals.LOADING.U8)) + else if (errorKind == RedisErrorKind.Loading) { connection.RecordConnectionFailed(ConnectionFailureType.Loading); } else { - connection.RecordConnectionFailed(ConnectionFailureType.ProtocolFailure, new RedisServerException(reader.GetOverview())); + connection.RecordConnectionFailed(ConnectionFailureType.ProtocolFailure, new RedisServerException(RedisErrorKind.ConnectionFault, message.Flags, reader.GetOverview())); } } diff --git a/src/StackExchange.Redis/ServerSelectionStrategy.cs b/src/StackExchange.Redis/ServerSelectionStrategy.cs index 27358e8c74..80c9b9efe6 100644 --- a/src/StackExchange.Redis/ServerSelectionStrategy.cs +++ b/src/StackExchange.Redis/ServerSelectionStrategy.cs @@ -52,9 +52,9 @@ internal sealed partial class ServerSelectionStrategy private int anyStartOffset = SharedRandom.Next(); // initialize to a random value so routing isn't uniform #if NET - private static Random SharedRandom => Random.Shared; + internal static Random SharedRandom => Random.Shared; #else - private static Random SharedRandom { get; } = new(); + internal static Random SharedRandom { get; } = new(); #endif private ServerEndPoint[]? map; diff --git a/tests/StackExchange.Redis.Tests/CircuitBreakerServerTests.cs b/tests/StackExchange.Redis.Tests/CircuitBreakerServerTests.cs index 074dafcd9a..54783b8b09 100644 --- a/tests/StackExchange.Redis.Tests/CircuitBreakerServerTests.cs +++ b/tests/StackExchange.Redis.Tests/CircuitBreakerServerTests.cs @@ -60,19 +60,17 @@ private sealed class CountingCircuitBreaker : CircuitBreaker private sealed class CountingAccumulator(CountingCircuitBreaker owner) : Accumulator { - public override bool ObserveResult(in CircuitBreakerContext context) + public override void ObserveResult(in FaultContext context) { - if (context.Success) + if (context.IsFault) { - Interlocked.Increment(ref owner._successes); + Interlocked.Increment(ref owner._failures); + owner.LastFault = context.Fault; } else { - Interlocked.Increment(ref owner._failures); - owner.LastFault = context.Fault; + Interlocked.Increment(ref owner._successes); } - - return true; // stay healthy; we're only here to observe, not to trip the connection } public override bool IsHealthy() => true; diff --git a/tests/StackExchange.Redis.Tests/CircuitBreakerUnitTests.cs b/tests/StackExchange.Redis.Tests/CircuitBreakerUnitTests.cs index dad3dd015d..ab0b15545d 100644 --- a/tests/StackExchange.Redis.Tests/CircuitBreakerUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/CircuitBreakerUnitTests.cs @@ -48,33 +48,7 @@ public void None_IsAlwaysHealthy() { var acc = CircuitBreaker.None.CreateAccumulator(); // even a solid wall of tracked failures never trips the no-op breaker - Assert.True(Record(acc, 10_000, new RedisTimeoutException("boom", CommandStatus.Unknown))); - } - - [Fact] - public void NonEvaluatingObservation_IsNeverTreatedAsUnhealthy() - { - // a deliberately naive breaker whose accumulator *always* reports unhealthy - e.g. one that - // returns default(false) whether or not it was actually asked to evaluate. A success is - // observed without evaluation, so that bogus verdict must be ignored (not read as a trip); - // only a genuine evaluation (a fault) is allowed to report unhealthy. - var acc = new AlwaysUnhealthyBreaker().CreateAccumulator(); - - Assert.True(acc.ObserveResult((Exception?)null)); // success -> not evaluating -> verdict ignored - Assert.False(acc.ObserveResult(Timeout())); // fault -> evaluating -> verdict honoured - } - - // never reports healthy, even when not evaluating; stands in for a buggy/naive custom breaker - private sealed class AlwaysUnhealthyBreaker : CircuitBreaker - { - public override Accumulator CreateAccumulator() => new Acc(); - - private sealed class Acc : Accumulator - { - public override bool ObserveResult(in CircuitBreakerContext context) => false; - public override bool IsHealthy() => false; - public override void Reset() { } - } + Assert.True(Record(acc, 10_000, new RedisTimeoutException(CommandFlags.None, "boom", CommandStatus.Unknown))); } #if NET8_0_OR_GREATER @@ -128,23 +102,6 @@ public void UntrackedExceptions_CountAsSuccess() Assert.False(Record(acc, 100, Timeout())); } - [Fact] - public void CustomTrackedExceptions_AreHonoured() - { - var time = new ManualTimeProvider(); - var acc = Build( - time, - failureRateThreshold: 1, - minimumNumberOfFailures: 1, - trackedExceptions: [typeof(InvalidOperationException)]).CreateAccumulator(); - - // the default Redis faults are now *un*tracked, so they read as success - Assert.True(Record(acc, 100, Timeout())); - - // whereas our nominated type trips it - Assert.False(Record(acc, 100, new InvalidOperationException("tracked"))); - } - [Fact] public void OldFailures_AgeOutOfTheWindow() { @@ -194,14 +151,12 @@ public void Reset_DiscardsHistory() private static CircuitBreaker Build( TimeProvider time, double failureRateThreshold, - int minimumNumberOfFailures, - Type[]? trackedExceptions = null) + int minimumNumberOfFailures) => new CircuitBreaker.Builder { FailureRateThreshold = failureRateThreshold, MinimumNumberOfFailures = minimumNumberOfFailures, MetricsWindowSize = TimeSpan.FromSeconds(10), - TrackedExceptions = trackedExceptions, TimeProvider = time, }.Create(); @@ -222,18 +177,17 @@ private sealed class ManualTimeProvider : TimeProvider } #endif - private static RedisTimeoutException Timeout() => new("timeout", CommandStatus.Unknown); + private static RedisTimeoutException Timeout() => new(CommandFlags.None, "timeout", CommandStatus.Unknown); private static bool Record(CircuitBreaker.Accumulator accumulator, int count, Exception? fault = null) { - // success/failure is derived from the presence of a fault; pass a fault for a failure, none for a success - var context = new CircuitBreaker.CircuitBreakerContext(fault); - bool healthy = true; + // Trip applies the IsFailure gate (a fault the breaker doesn't consider a failure is counted as a + // success), then records via ObserveResult; call it rather than ObserveResult directly so the gate runs for (int i = 0; i < count; i++) { - healthy = accumulator.ObserveResult(in context); + accumulator.Trip(fault); } - return healthy; + return accumulator.IsHealthy(); } } diff --git a/tests/StackExchange.Redis.Tests/ControllableProbe.cs b/tests/StackExchange.Redis.Tests/ControllableProbe.cs new file mode 100644 index 0000000000..4d8e47dc77 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ControllableProbe.cs @@ -0,0 +1,18 @@ +using System.Net; +using System.Threading.Tasks; +using StackExchange.Redis.Availability; + +namespace StackExchange.Redis.Tests; + +// A health-check probe whose verdict is driven by the test: nominated endpoints report unhealthy on +// demand, everything else is healthy. This keeps a specific member deselected deterministically, even +// after its physical connection reconnects underneath us. +internal sealed class ControllableProbe : HealthCheck.HealthCheckProbe +{ + private volatile EndPoint? _down; + + public void MarkDown(EndPoint endpoint) => _down = endpoint; + + public override Task CheckHealthAsync(HealthCheck healthCheck, IServer server) + => Equals(server.EndPoint, _down) ? UnhealthyTask : HealthyTask; +} diff --git a/tests/StackExchange.Redis.Tests/MultiGroupTests/CircuitBreakerRerouteTests.cs b/tests/StackExchange.Redis.Tests/MultiGroupTests/CircuitBreakerRerouteTests.cs index 18436db467..da739ae8f7 100644 --- a/tests/StackExchange.Redis.Tests/MultiGroupTests/CircuitBreakerRerouteTests.cs +++ b/tests/StackExchange.Redis.Tests/MultiGroupTests/CircuitBreakerRerouteTests.cs @@ -8,7 +8,7 @@ namespace StackExchange.Redis.Tests.MultiGroupTests; [RunPerProtocol] -public class CircuitBreakerRerouteTests(ITestOutputHelper log) +public class CircuitBreakerRerouteTests(ITestOutputHelper log) : TestBase(log) { // A circuit-breaker trip must steer the group away from the affected member *promptly* - i.e. via // the shim's ConnectionFailed(CircuitBreaker) fast-path, not by waiting for the next health-check @@ -23,9 +23,9 @@ public async Task CircuitBreakerTrip_ReroutesAwayFromMember() EndPoint bravo = new DnsEndPoint("bravo", 6379); EndPoint charlie = new DnsEndPoint("charlie", 6379); - using var serverA = new InProcessTestServer(log, endpoint: alpha); - using var serverB = new InProcessTestServer(log, endpoint: bravo); - using var serverC = new InProcessTestServer(log, endpoint: charlie); + using var serverA = new InProcessTestServer(Output, endpoint: alpha); + using var serverB = new InProcessTestServer(Output, endpoint: bravo); + using var serverC = new InProcessTestServer(Output, endpoint: charlie); var breaker = new FlipBreaker(); var probe = new ControllableProbe(); @@ -60,7 +60,7 @@ public async Task CircuitBreakerTrip_ReroutesAwayFromMember() int circuitBreakerEvents = 0; typed.ConnectionFailed += (_, e) => { - log.WriteLine($"ConnectionFailed: {e.FailureType} @ {e.EndPoint}"); + Log($"ConnectionFailed: {e.FailureType} @ {e.EndPoint}"); if (e.FailureType == ConnectionFailureType.CircuitBreaker) { Interlocked.Increment(ref circuitBreakerEvents); @@ -77,7 +77,7 @@ public async Task CircuitBreakerTrip_ReroutesAwayFromMember() breaker.Trip(); var db = conn.GetDatabase(); var fault = await Assert.ThrowsAnyAsync(() => db.ExecuteAsync("nonesuch")); - log.WriteLine($"observed fault: {fault.GetType().Name}: {fault.Message}"); + Log($"observed fault: {fault.GetType().Name}: {fault.Message}"); // wait (briefly) for the fast-path to react; nothing else can move us within this window var moved = await WaitForActiveAsync(conn, notMember: members[0], timeout: TimeSpan.FromSeconds(5)); @@ -113,20 +113,13 @@ private sealed class FlipBreaker : CircuitBreaker private sealed class Acc(FlipBreaker owner) : Accumulator { - public override bool ObserveResult(in CircuitBreakerContext context) => !owner._tripped; + // this breaker trips on demand regardless of *what* faulted, so treat every observed fault as a + // failure - otherwise Trip's IsFailure gate would filter out non-failure faults (e.g. an unknown + // command) before the tripped state is ever consulted + protected override bool IsFailure(in FaultContext fault) => true; + public override void ObserveResult(in FaultContext context) { } public override bool IsHealthy() => !owner._tripped; public override void Reset() { } } } - - // reports the nominated endpoints unhealthy on demand; everything else is healthy. This keeps the - // tripped member deselected even after its physical connection reconnects. - private sealed class ControllableProbe : HealthCheck.HealthCheckProbe - { - private volatile EndPoint? _down; - public void MarkDown(EndPoint endpoint) => _down = endpoint; - - public override Task CheckHealthAsync(HealthCheck healthCheck, IServer server) - => Equals(server.EndPoint, _down) ? UnhealthyTask : HealthyTask; - } } diff --git a/tests/StackExchange.Redis.Tests/RetryPolicyUnitTests.cs b/tests/StackExchange.Redis.Tests/ReconnectRetryPolicyUnitTests.cs similarity index 99% rename from tests/StackExchange.Redis.Tests/RetryPolicyUnitTests.cs rename to tests/StackExchange.Redis.Tests/ReconnectRetryPolicyUnitTests.cs index 78036e6355..b3ea620f56 100644 --- a/tests/StackExchange.Redis.Tests/RetryPolicyUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/ReconnectRetryPolicyUnitTests.cs @@ -11,7 +11,7 @@ namespace StackExchange.Redis.Tests; -public class RetryPolicyUnitTests(ITestOutputHelper log) +public class ReconnectRetryPolicyUnitTests(ITestOutputHelper log) { [Theory] [InlineData(FailureMode.Success)] diff --git a/tests/StackExchange.Redis.Tests/RetryTests/CommandRetryPolicyUnitTests.cs b/tests/StackExchange.Redis.Tests/RetryTests/CommandRetryPolicyUnitTests.cs new file mode 100644 index 0000000000..b8e630da13 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RetryTests/CommandRetryPolicyUnitTests.cs @@ -0,0 +1,191 @@ +using System.Threading; +using StackExchange.Redis.Availability; +using StackExchange.Redis.Interfaces; +using Xunit; + +namespace StackExchange.Redis.Tests.RetryTests; + +public class CommandRetryPolicyUnitTests +{ + // --- RetryPolicy.CanRetry: spoofed fault scenarios ------------------------------- + + // Builds a FaultContext for a spoofed server error of the given kind, carrying the given + // command-flags, and asks the policy whether it may be retried. + private static RetryPolicy.RetryPolicyResult CanRetry(RedisErrorKind kind, CommandFlags flags, RetryPolicy? policy = null) + { + // the exception carries both the Kind and the command-flags; FaultContext reads them back + var fault = new FaultContext(new RedisServerException(kind, flags, kind.ToString())); + return (policy ?? new RetryPolicy()).CanRetry(in fault); + } + + // The command's retry-category is checked against the policy's max category: the default max is + // CommandRetryWriteLastWins, so anything at-or-below that is in-range, and anything with more + // side-effects is not. Using a transient LOADING fault so the error-kind check permits a retry + // whenever the category is in-range - isolating the category logic. + [Theory] + [InlineData(CommandFlags.CommandRetryAlways, true)] + [InlineData(CommandFlags.CommandRetryConnection, true)] + [InlineData(CommandFlags.CommandRetryReadOnly, true)] + [InlineData(CommandFlags.CommandRetryWriteChecked, true)] + [InlineData(CommandFlags.CommandRetryWriteLastWins, true)] // == default max + [InlineData(CommandFlags.CommandRetryWriteAccumulating, false)] // beyond default max + [InlineData(CommandFlags.CommandRetryServerAdmin, false)] + [InlineData(CommandFlags.CommandRetryNever, false)] + [InlineData(CommandFlags.None, false)] // unspecified => assume worst (accumulating) => beyond default max + public void CanRetry_CategoryVersusDefaultMax(CommandFlags category, bool expectRetry) + { + var result = CanRetry(RedisErrorKind.Loading, category); + Assert.Equal(expectRetry, result != RetryPolicy.RetryPolicyResult.None); + } + + // With an in-range category (== default max), the outcome is decided purely by whether the error + // is transient: LOADING is worth retrying, WRONGTYPE is an application error that will not fix itself. + [Theory] + [InlineData(RedisErrorKind.Loading, true)] // still loading the dataset - transient + [InlineData(RedisErrorKind.ClusterDown, true)] // slot temporarily unserved - transient + [InlineData(RedisErrorKind.WrongType, false)] // wrong data type - application error + [InlineData(RedisErrorKind.NoPermission, false)] // ACL - application error + public void CanRetry_ErrorKindGatesRetry_WhenInRange(RedisErrorKind kind, bool expectRetry) + { + var result = CanRetry(kind, CommandFlags.CommandRetryWriteLastWins); + Assert.Equal(expectRetry, result != RetryPolicy.RetryPolicyResult.None); + } + + // "never" and "always" adjust only the category range - they do not override the error-kind check: + // an "always" command still won't retry an application error, and a "never" command won't retry even + // a transient one. + [Theory] + [InlineData(CommandFlags.CommandRetryAlways, RedisErrorKind.Loading, true)] + [InlineData(CommandFlags.CommandRetryAlways, RedisErrorKind.WrongType, false)] + [InlineData(CommandFlags.CommandRetryNever, RedisErrorKind.Loading, false)] + [InlineData(CommandFlags.CommandRetryNever, RedisErrorKind.WrongType, false)] + public void CanRetry_NeverAndAlwaysAffectRangeNotErrorKind(CommandFlags category, RedisErrorKind kind, bool expectRetry) + { + var result = CanRetry(kind, category); + Assert.Equal(expectRetry, result != RetryPolicy.RetryPolicyResult.None); + } + + // When a retry is permitted, it normally offers both the same server and a failover server; but a + // "server specific" (sticky) command must not move endpoints, so only the same-server option remains. + [Theory] + [InlineData(CommandFlags.None, RetryPolicy.RetryPolicyResult.SameServer | RetryPolicy.RetryPolicyResult.FailoverServer)] + [InlineData(Message.CommandServerSpecific, RetryPolicy.RetryPolicyResult.SameServer)] + public void CanRetry_ServerSpecificRestrictsToSameServer(CommandFlags extra, RetryPolicy.RetryPolicyResult expected) + { + // in-range category (== default max) + transient error => a retry is offered; the sticky flag + // only changes *where* the retry may go, not *whether* it happens. + var result = CanRetry(RedisErrorKind.Loading, CommandFlags.CommandRetryWriteLastWins | extra); + Assert.Equal(expected, result); + } + + // The sticky (server-specific) flag lives outside the retry-category region, so it is masked off + // before the category-vs-max comparison and must not change the range verdict (retry-at-all vs none) + // - it only affects the same/failover choice, covered above. + [Theory] + [InlineData(CommandFlags.CommandRetryReadOnly, true)] // in range + [InlineData(CommandFlags.CommandRetryServerAdmin, false)] // beyond default max + public void CanRetry_ServerSpecificDoesNotAffectRange(CommandFlags category, bool expectRetry) + { + var withoutFlag = CanRetry(RedisErrorKind.Loading, category); + var withFlag = CanRetry(RedisErrorKind.Loading, category | Message.CommandServerSpecific); + + Assert.Equal(expectRetry, withoutFlag != RetryPolicy.RetryPolicyResult.None); + Assert.Equal(expectRetry, withFlag != RetryPolicy.RetryPolicyResult.None); + } + + // --- RetryDatabase.CanRetry: attempt accounting ---------------------------------- + + // With max-attempts-before-failover pinned equal to max-attempts, the failover path is disabled, so + // this exercises pure same-server attempt counting. A transient LOADING fault on an in-range command + // means the policy would allow a retry; the only gate is the attempt counter: with MaxAttempts=3, + // attempts 1 and 2 may retry, attempt 3 is exhausted. Because we never fail over, the out "delay" is + // never cancellable (that is how "don't wait for failover" is expressed) and the ref "failover" is + // left untouched. + [Theory] + [InlineData(1, true)] + [InlineData(2, true)] + [InlineData(3, false)] + public void RetryDatabase_CanRetry_MaxAttempts_NoFailover(int attempt, bool expected) + { + var policy = new RetryPolicy { MaxAttempts = 3, MaxAttemptsBeforeFailover = 3 }; + var db = new RetryDatabase(null!, policy); // CanRetry never touches the inner database + + using var cts = new CancellationTokenSource(); + var token = cts.Token; + var failover = token; + + var fault = new RedisServerException(RedisErrorKind.Loading, CommandFlags.CommandRetryWriteLastWins, "LOADING"); + var result = db.CanRetry(attempt, fault, ref failover, out var delay); + + Assert.Equal(expected, result); + Assert.False(delay.CanBeCanceled); // never waiting for a failover + Assert.Equal(token, failover); // ref failover untouched + } + + // MaxAttempts=4 with failover enabled after 2 attempts. A single "failover" token is threaded through + // the sequence to observe the state machine: attempts 1..3 return true, attempt 4 is exhausted. The + // interesting step is attempt 2 (== MaxAttemptsBeforeFailover): it still returns true, but now hands the + // failover token back as "delay" and clears the ref (we fail over only once); attempt 3 therefore sees + // no failover token and drops back to a plain same-server retry. + [Fact] + public void RetryDatabase_CanRetry_FailoverAtThreshold() + { + var policy = new RetryPolicy { MaxAttempts = 4, MaxAttemptsBeforeFailover = 2 }; + // failover is only armed when the inner database advertises the feature; supply it explicitly + var db = new RetryDatabase(null!, policy, DatabaseFeatureFlags.Failover); + + using var cts = new CancellationTokenSource(); + var token = cts.Token; + var failover = token; + var fault = new RedisServerException(RedisErrorKind.Loading, CommandFlags.CommandRetryWriteLastWins, "LOADING"); + + // attempt 1: plain same-server retry; failover token not yet consumed + Assert.True(db.CanRetry(1, fault, ref failover, out var delay)); + Assert.False(delay.CanBeCanceled); + Assert.Equal(token, failover); + + // attempt 2 (== MaxAttemptsBeforeFailover): still a retry, but now fail over - "delay" becomes the + // failover token and the ref is cleared to None so it only fires once + Assert.True(db.CanRetry(2, fault, ref failover, out delay)); + Assert.Equal(token, delay); + Assert.True(delay.CanBeCanceled); + Assert.Equal(CancellationToken.None, failover); + + // attempt 3: failover already spent (ref is None) -> back to a same-server retry + Assert.True(db.CanRetry(3, fault, ref failover, out delay)); + Assert.False(delay.CanBeCanceled); + Assert.Equal(CancellationToken.None, failover); + + // attempt 4: no retries left + Assert.False(db.CanRetry(4, fault, ref failover, out delay)); + Assert.False(delay.CanBeCanceled); + } + + // As above, but with the sticky (server-specific) flag set: the policy now permits only same-server + // retries, so there is no failover option at the threshold. Current behaviour: CanRetry returns *false* + // at attempt 2 - the command gives up rather than continuing on the same server - because the threshold + // branch (attempt == MaxAttemptsBeforeFailover) requires FailoverServer permission and does not fall + // back to a same-server retry. It also consumes the failover token as a side-effect of that branch. + [Fact] + public void RetryDatabase_CanRetry_ServerSpecific_CannotFailover() + { + var policy = new RetryPolicy { MaxAttempts = 4, MaxAttemptsBeforeFailover = 2 }; + var db = new RetryDatabase(null!, policy, DatabaseFeatureFlags.Failover); + + using var cts = new CancellationTokenSource(); + var token = cts.Token; + var failover = token; + const CommandFlags flags = CommandFlags.CommandRetryWriteLastWins | Message.CommandServerSpecific; + var fault = new RedisServerException(RedisErrorKind.Loading, flags, "LOADING"); + + // attempt 1: same-server retry; failover token untouched + Assert.True(db.CanRetry(1, fault, ref failover, out var delay)); + Assert.False(delay.CanBeCanceled); + Assert.Equal(token, failover); + + // attempt 2 (== MaxAttemptsBeforeFailover): sticky forbids failover -> gives up (false), even though + // attempts remain; the failover token is still consumed to None as a side-effect of the branch + Assert.False(db.CanRetry(2, fault, ref failover, out delay)); + Assert.Equal(CancellationToken.None, failover); + } +} diff --git a/tests/StackExchange.Redis.Tests/RetryTests/RetryEndToEndTests.cs b/tests/StackExchange.Redis.Tests/RetryTests/RetryEndToEndTests.cs new file mode 100644 index 0000000000..e2b9583f7b --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RetryTests/RetryEndToEndTests.cs @@ -0,0 +1,153 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using StackExchange.Redis.Availability; +using StackExchange.Redis.Server; +using Xunit; + +namespace StackExchange.Redis.Tests.RetryTests; + +[RunPerProtocol] +public class RetryEndToEndTests(ITestOutputHelper log) : TestBase(log) +{ + // End-to-end: a server that answers the first couple of GETs with a transient LOADING error, then + // serves normally. Wrapping the database with .WithRetry should transparently ride through the LOADING + // responses; we can then observe (via the server's counter) that it really did take three GETs before + // one succeeded. + [Fact] + public async Task WithRetry_RidesOutTransientLoading() + { + using var server = new LoadingServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + Assert.True(conn.IsConnected); + + var db = conn.GetDatabase(); + + RedisKey key = "retry:loading"; + Assert.True(await db.StringSetAsync(key, "hello")); // seed the value before we start failing GETs + + // queue up two LOADING responses; the third GET should succeed + server.LoadingOps = 2; + + // zero delay/jitter so the test isn't paying the default ~1s retry backoff between attempts + var policy = new RetryPolicy + { + MaxAttempts = 3, + RetryDelay = TimeSpan.Zero, + JitterMax = TimeSpan.Zero, + }; + var retryDb = db.WithRetry(policy); + + var value = await retryDb.StringGetAsync(key); + + Assert.Equal("hello", value); // retries rode out the LOADING responses + Assert.Equal(0, server.LoadingOps); // both LOADING responses were consumed + Assert.Equal(3, server.GetOpsReceived); // 2 x LOADING + 1 x success + } + + // Multi-group + WithRetry: two backends hold *different* values for the same key. The group is weighted + // towards A, so a normal read returns A's value. When A drops into LOADING, the faults trip A's + // (deliberately hair-trigger) circuit-breaker; the group reroutes to B, and the retry wrapper rides that + // failover transparently - the very same StringGet now returns B's value, with no caller intervention and + // no configuration change. + [Fact] + public async Task WithRetry_FailsOverBetweenGroupsOnLoading() + { + EndPoint alpha = new DnsEndPoint("alpha", 6379); + EndPoint bravo = new DnsEndPoint("bravo", 6379); + using var serverA = new InProcessTestServer(Output, endpoint: alpha); + using var serverB = new InProcessTestServer(Output, endpoint: bravo); + + RedisKey key = "retry:multigroup"; + + // seed each backend with its own distinct value (direct connections, so each cache gets its own) + await using (var seedA = await serverA.ConnectAsync()) + { + Assert.True(await seedA.GetDatabase().StringSetAsync(key, "from-A")); + } + await using (var seedB = await serverB.ConnectAsync()) + { + Assert.True(await seedB.GetDatabase().StringSetAsync(key, "from-B")); + } + + var probe = new ControllableProbe(); + + // A carries a hair-trigger breaker (trips on the first fault); B keeps the default (never trips here) + var configA = serverA.GetClientConfig(); + configA.CircuitBreaker = new CircuitBreaker.Builder + { + MinimumNumberOfFailures = 1, + FailureRateThreshold = 1, + }; + + ConnectionGroupMember[] members = + [ + new(configA, "A") { Weight = 9 }, // highest weight -> initially active + new(serverB.GetClientConfig(), "B") { Weight = 1 }, // failover target + ]; + + var options = new MultiGroupOptions + { + HealthCheck = new HealthCheck + { + Probe = probe, + Interval = TimeSpan.FromMinutes(30), // huge: the breaker fast-path is what reroutes us + ProbeCount = 1, + ProbeTimeout = TimeSpan.FromSeconds(5), + }, + }; + + await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members, options); + Assert.True(conn.IsConnected); + Assert.Same(members[0], conn.ActiveMember); // A is active (highest weight) + + // failover enabled, plenty of attempts, no artificial delay between them + var policy = new RetryPolicy + { + MaxAttempts = 20, + MaxAttemptsBeforeFailover = 1, + RetryDelay = TimeSpan.Zero, + JitterMax = TimeSpan.Zero, + }; + var db = conn.GetDatabase().WithRetry(policy); + + // normal read: routed to the active (weighted) member, A + string? before = await db.StringGetAsync(key); + Assert.Equal("from-A", before); + + // knock A into LOADING and hold it down; nothing else about the setup changes + serverA.IsLoading = true; + probe.MarkDown(alpha); + + // the *same* call now transparently rides the circuit-break failover across to B + string? after = await db.StringGetAsync(key); + Assert.Equal("from-B", after); + Assert.Same(members[1], conn.ActiveMember); // we really did move to B + } + + // An in-proc server that fails the first LoadingOps GET operations with a transient LOADING error + // (decrementing the counter each time), then serves normally. Every GET bumps GetOpsReceived so the + // test can confirm how many attempts actually reached the server. + private sealed class LoadingServer(ITestOutputHelper? log) : InProcessTestServer(log) + { + // the server core processes operations under a lock (single-threaded, like Redis), so plain fields + // are fine here + public int GetOpsReceived { get; private set; } + + public int LoadingOps { get; set; } + + protected override TypedRedisValue Get(RedisClient client, in RedisRequest request) + { + GetOpsReceived++; + + // while LOADING ops remain, consume one and reply with a transient LOADING error + if (LoadingOps > 0) + { + LoadingOps--; + return TypedRedisValue.Error("LOADING Redis is loading the dataset in memory"); + } + + return base.Get(client, in request); + } + } +} diff --git a/toys/StackExchange.Redis.Server/RedisServer.cs b/toys/StackExchange.Redis.Server/RedisServer.cs index 26f1a2fc37..77ad4f1eda 100644 --- a/toys/StackExchange.Redis.Server/RedisServer.cs +++ b/toys/StackExchange.Redis.Server/RedisServer.cs @@ -160,8 +160,18 @@ protected override void AppendStats(StringBuilder sb) public string Password { get; set; } = ""; + /// + /// When set, every command is rejected with a LOADING error, mimicking a server that is + /// still loading its dataset into memory. + /// + public bool IsLoading { get; set; } + public override TypedRedisValue Execute(RedisClient client, in RedisRequest request) { + if (IsLoading) + { + return TypedRedisValue.Error("LOADING"); + } var pw = Password; if (!string.IsNullOrEmpty(pw) & !client.IsAuthenticated) { From d91efa1f8c7358145950ab8471aea5eab5090a56 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 17 Jul 2026 15:51:33 +0100 Subject: [PATCH 18/33] CI fixes --- docs/ActiveActive.md | 3 +-- .../KeyspaceIsolation/KeyPrefixedBatch.cs | 1 - .../KeyspaceIsolation/KeyPrefixedDatabase.cs | 17 +++++++++++++---- .../KeyspaceIsolation/KeyPrefixedTransaction.cs | 1 - .../CircuitBreakerServerTests.cs | 17 ++++++++++++----- .../KeyPrefixedDatabaseTests.cs | 7 ------- .../WithKeyPrefixTests.cs | 1 + 7 files changed, 27 insertions(+), 20 deletions(-) diff --git a/docs/ActiveActive.md b/docs/ActiveActive.md index daa49f8263..aab277ce89 100644 --- a/docs/ActiveActive.md +++ b/docs/ActiveActive.md @@ -12,6 +12,7 @@ The Active:Active feature provides automatic failover and intelligent routing ac The features for Active:Active are available in the `Availability` sub-namespace: ``` csharp +using StackExchange.Redis; using StackExchange.Redis.Availability; ``` The library automatically selects the best available endpoint based on: @@ -33,8 +34,6 @@ This enables scenarios such as: To create an Active:Active connection, use `ConnectionMultiplexer.ConnectGroupAsync()` with an array of `ConnectionGroupMember` instances: ```csharp -using StackExchange.Redis; - // Define your Redis endpoints ConnectionGroupMember[] members = [ new("us-east.redis.example.com:6379", name: "US East"), diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedBatch.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedBatch.cs index a5e07a011e..710e8cabdf 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedBatch.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedBatch.cs @@ -6,7 +6,6 @@ internal sealed class KeyPrefixedBatch : KeyPrefixed, IBatch { public KeyPrefixedBatch(IBatch inner, byte[] prefix) : base(inner, prefix) { - inner.RejectFlags(DatabaseFeatureFlags.Batch | DatabaseFeatureFlags.Transaction); } private protected override DatabaseFeatureFlags GetDatabaseFeatures() diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index 15deebb89e..50422bcf90 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Net; +using StackExchange.Redis.Interfaces; namespace StackExchange.Redis.KeyspaceIsolation { @@ -10,11 +11,19 @@ public KeyPrefixedDatabase(IDatabase inner, byte[] prefix) : base(inner, prefix) { } - public IBatch CreateBatch(object? asyncState = null) => - new KeyPrefixedBatch(Inner.CreateBatch(asyncState), Prefix); + public IBatch CreateBatch(object? asyncState = null) + { + // reject nesting based on *this* database (the thing being wrapped), not the freshly + // created inner batch - which trivially carries the Batch flag and would always trip + this.RejectFlags(DatabaseFeatureFlags.Batch | DatabaseFeatureFlags.Transaction); + return new KeyPrefixedBatch(Inner.CreateBatch(asyncState), Prefix); + } - public ITransaction CreateTransaction(object? asyncState = null) => - new KeyPrefixedTransaction(Inner.CreateTransaction(asyncState), Prefix); + public ITransaction CreateTransaction(object? asyncState = null) + { + this.RejectFlags(DatabaseFeatureFlags.Batch | DatabaseFeatureFlags.Transaction); + return new KeyPrefixedTransaction(Inner.CreateTransaction(asyncState), Prefix); + } public RedisValue DebugObject(RedisKey key, CommandFlags flags = CommandFlags.None) => Inner.DebugObject(ToInner(key), flags); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedTransaction.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedTransaction.cs index 7b7c6f0ced..139f502674 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedTransaction.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedTransaction.cs @@ -7,7 +7,6 @@ internal sealed class KeyPrefixedTransaction : KeyPrefixed, ITrans { public KeyPrefixedTransaction(ITransaction inner, byte[] prefix) : base(inner, prefix) { - inner.RejectFlags(DatabaseFeatureFlags.Batch | DatabaseFeatureFlags.Transaction); } private protected override DatabaseFeatureFlags GetDatabaseFeatures() diff --git a/tests/StackExchange.Redis.Tests/CircuitBreakerServerTests.cs b/tests/StackExchange.Redis.Tests/CircuitBreakerServerTests.cs index 54783b8b09..81ca78ee08 100644 --- a/tests/StackExchange.Redis.Tests/CircuitBreakerServerTests.cs +++ b/tests/StackExchange.Redis.Tests/CircuitBreakerServerTests.cs @@ -30,20 +30,27 @@ public async Task CircuitBreakerObservesMessageResults() var successesAfterGetSets = breaker.Successes; - // and something the server doesn't support -> server replies with an error -> observed as a fault - var fault = await Assert.ThrowsAnyAsync(() => db.ExecuteAsync("nonesuch")); - Output.WriteLine($"execute fault: {fault.GetType().Name}: {fault.Message}"); + // knock the server offline: it now replies LOADING to every command, which (unlike an + // application-level "unknown command") is a genuine availability fault the breaker observes. + // flip it straight back off so no background heartbeat can observe a second LOADING reply - + // the fault for our command is recorded synchronously as it completes, before the await returns. + server.IsLoading = true; + var fault = await Assert.ThrowsAsync(() => db.StringGetAsync(key)); + server.IsLoading = false; + Assert.Equal(RedisErrorKind.Loading, fault.Kind); + Output.WriteLine($"loading fault: {fault.GetType().Name}: {fault.Message}"); Output.WriteLine($"observed successes={breaker.Successes}, failures={breaker.Failures}, lastFault={breaker.LastFault?.GetType().Name}"); // the get/sets were observed as successes Assert.True(successesAfterGetSets > 0, "expected the successful operations to be observed"); - // exactly one fault (the unsupported command), captured as the clean server error. A healthy + // exactly one fault (the LOADING reply), captured as the clean server error. A healthy // breaker must NOT tear the connection down: a regression there shows up here as a // RedisConnectionException (and extra faults) rather than this RedisServerException. Assert.Equal(1, breaker.Failures); - Assert.IsType(breaker.LastFault); + var serverFault = Assert.IsType(breaker.LastFault); + Assert.Equal(RedisErrorKind.Loading, serverFault.Kind); } // a minimal breaker for tests: shares counters across all accumulators it creates, so we can diff --git a/tests/StackExchange.Redis.Tests/KeyPrefixedDatabaseTests.cs b/tests/StackExchange.Redis.Tests/KeyPrefixedDatabaseTests.cs index ac8d6b05d2..3182417a55 100644 --- a/tests/StackExchange.Redis.Tests/KeyPrefixedDatabaseTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyPrefixedDatabaseTests.cs @@ -64,13 +64,6 @@ public void DebugObject() mock.Received().DebugObject("prefix:key", CommandFlags.None); } - [Fact] - public void Get_Database() - { - mock.Database.Returns(123); - Assert.Equal(123, prefixed.Database); - } - [Fact] public void HashDecrement_1() { diff --git a/tests/StackExchange.Redis.Tests/WithKeyPrefixTests.cs b/tests/StackExchange.Redis.Tests/WithKeyPrefixTests.cs index acbef74cf7..b70d215ee1 100644 --- a/tests/StackExchange.Redis.Tests/WithKeyPrefixTests.cs +++ b/tests/StackExchange.Redis.Tests/WithKeyPrefixTests.cs @@ -106,6 +106,7 @@ public async Task ConditionTest() var prefix = Me() + ":"; var foo = raw.WithKeyPrefix(prefix); + Output.WriteLine($"prefixed db features: {foo}"); // should be KeyPrefix (not Transaction/Batch) raw.KeyDelete(prefix + "abc", CommandFlags.FireAndForget); raw.KeyDelete(prefix + "i", CommandFlags.FireAndForget); From 6e7511d50b54229257e3bbb133e74167a99956b8 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 17 Jul 2026 15:59:20 +0100 Subject: [PATCH 19/33] avoid timing glitch in CircuitBreakerTrip_ReroutesAwayFromMember --- .../MultiGroupTests/CircuitBreakerRerouteTests.cs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/StackExchange.Redis.Tests/MultiGroupTests/CircuitBreakerRerouteTests.cs b/tests/StackExchange.Redis.Tests/MultiGroupTests/CircuitBreakerRerouteTests.cs index da739ae8f7..abd442e60f 100644 --- a/tests/StackExchange.Redis.Tests/MultiGroupTests/CircuitBreakerRerouteTests.cs +++ b/tests/StackExchange.Redis.Tests/MultiGroupTests/CircuitBreakerRerouteTests.cs @@ -57,13 +57,15 @@ public async Task CircuitBreakerTrip_ReroutesAwayFromMember() await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members, options); var typed = Assert.IsType(conn); - int circuitBreakerEvents = 0; + // completes the first time we see a ConnectionFailed(CircuitBreaker); we await this (rather than + // polling a counter) so the assertion is deterministic and not subject to event-timing races + var circuitBreakerEvents = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); typed.ConnectionFailed += (_, e) => { Log($"ConnectionFailed: {e.FailureType} @ {e.EndPoint}"); if (e.FailureType == ConnectionFailureType.CircuitBreaker) { - Interlocked.Increment(ref circuitBreakerEvents); + circuitBreakerEvents.TrySetResult(true); } }; @@ -84,7 +86,12 @@ public async Task CircuitBreakerTrip_ReroutesAwayFromMember() Assert.True(moved, "expected the circuit-breaker trip to reroute away from member A"); Assert.Same(members[1], conn.ActiveMember); // B is the next-highest weight - Assert.True(circuitBreakerEvents > 0, "expected a ConnectionFailed event with FailureType == CircuitBreaker"); + + // a ConnectionFailed event with FailureType == CircuitBreaker must have fired; await it (with a + // timeout linked to the ambient test cancellation) rather than racing on a counter read + using var cbCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cbCts.CancelAfter(TimeSpan.FromSeconds(5)); + Assert.True(await circuitBreakerEvents.Task.WaitAsync(cbCts.Token), "expected a ConnectionFailed event with FailureType == CircuitBreaker"); } private static async Task WaitForActiveAsync( From 7441909d06f1eb8e6381640ca005ee6527fe12a3 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 17 Jul 2026 16:04:49 +0100 Subject: [PATCH 20/33] version 3.1 --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 461b0972f6..b4ab1f0283 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "version": "3.0", + "version": "3.1", "versionHeightOffset": 0, "versionHeightOffsetAppliesTo": "3.0", "assemblyVersion": "3.0", From f20989703fc5b91f2cd9e61ca3cd339136c71722 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 28 Jul 2026 14:35:48 +0100 Subject: [PATCH 21/33] Marc/aa2 retry tran (#3139) * Add retryable transactions to WithRetry Introduces a retryable transaction, obtained via WithRetry(...).CreateTransaction(). Mechanism: RetryTransaction is an [AutoDatabase] recorder. The generator funnels each builder call into (state, projection) - the same captured unit RetryDatabase replays - which RetryTransaction records while handing the caller a durable, still-incomplete proxy task. ExecuteAsync runs the retry loop: each attempt spins up a fresh one-shot inner transaction against the underlying database (for multi-group this resolves the currently active member, so a retry after failover lands on the new member), replays the recorded ops and constraints onto it, and awaits it. On clean completion the per-attempt outcomes are forwarded onto the durable proxies; on a transient fault the attempt is discarded and replayed; on terminal failure the proxies are faulted. The transaction's effective retry category is the most side-effecting of its operations (WATCH constraints excluded); it is injected onto the EXEC flags so the resulting fault carries it and the shared RetryPolicy/FaultContext gating applies exactly as for a single command. Retry policy math is extracted into a shared RetryController used by both RetryDatabase and RetryTransaction. Surface (all [Experimental] SER007): - new ITransactionAsync (async-only transaction; sibling of ITransaction, no shipped-API change) - new IRetryDatabase with CreateTransaction; WithRetry now returns it - RedisTransaction/KeyPrefixedTransaction also implement ITransactionAsync Includes end-to-end tests: rides out a transient EXEC failure, respects the aggregate category gate, and does not double-apply a discarded attempt. * Move CreateTransaction onto IDatabaseAsync Reworks the retryable-transaction surface so WithRetry keeps returning IDatabaseAsync and RetryDatabase never has to implement IDatabase (which would drag in the sync surface). CreateTransaction now lives on IDatabaseAsync, returning the async-only ITransactionAsync; IDatabase.CreateTransaction refines the return type to ITransaction via "new", with ITransaction : IBatch, ITransactionAsync. To avoid any ambiguity, ITransaction re-declares AddCondition/ExecuteAsync(flags) with "new" rather than relying on inheritance, so its surface is self-contained and its shipped-API lines are unchanged. - IDatabaseAsync gains CreateTransaction(): ITransactionAsync ([Experimental] SER007) - IDatabase.CreateTransaction is now "new ITransaction ..." (signature unchanged) - ITransaction : IBatch, ITransactionAsync (re-declares its two async members) - the source generator skips CreateTransaction/CreateBatch (not replayable round-trips) - explicit IDatabaseAsync.CreateTransaction bridges on RedisDatabase, MultiGroupDatabase, KeyPrefixedDatabase; KeyPrefixed base throws (reimplemented by the database); RetryTransaction throws (nested) - WithRetry returns IDatabaseAsync again; IRetryDatabase removed All retry/transaction/batch/keyprefix tests pass; full Release build across all TFMs. * Make ITransactionAsync stable (drop [Experimental]) ITransaction (stable) derives from ITransactionAsync, so the type must be stable too; the experimental gating stays on IDatabaseAsync.CreateTransaction (the new entry point). Also drops the now-redundant explicit ITransactionAsync from RedisTransaction and KeyPrefixedTransaction (implied by ITransaction : ITransactionAsync). * Drop IDatabase requirement from retry transaction Now that CreateTransaction is on IDatabaseAsync, RetryTransaction only needs an IDatabaseAsync source: it creates each attempt's transaction via source.CreateTransaction() and uses only AddCondition/ExecuteAsync (both on ITransactionAsync). Removes the IDatabase cast and its NotSupportedException path, so WithRetry(...).CreateTransaction() works over any IDatabaseAsync. --- .../AutoDatabaseGenerator.cs | 4 + .../Availability/MultiGroupDatabase.cs | 2 + .../Availability/RetryController.cs | 117 ++++++++ .../Availability/RetryDatabase.cs | 107 +------- .../Availability/RetryTransaction.cs | 253 ++++++++++++++++++ src/StackExchange.Redis/Condition.cs | 5 + .../Interfaces/IDatabase.cs | 3 +- .../Interfaces/IDatabaseAsync.cs | 10 + .../Interfaces/IInternalDatabaseAsync.cs | 13 + .../Interfaces/ITransaction.cs | 8 +- .../Interfaces/ITransactionAsync.cs | 29 ++ .../KeyspaceIsolation/KeyPrefixed.cs | 5 + .../KeyspaceIsolation/KeyPrefixedDatabase.cs | 3 + .../KeyPrefixedTransaction.cs | 5 +- .../PublicAPI/PublicAPI.Unshipped.txt | 4 + src/StackExchange.Redis/RedisDatabase.cs | 4 + src/StackExchange.Redis/RedisTransaction.cs | 23 +- .../RetryTests/CommandRetryPolicyUnitTests.cs | 20 +- .../RetryTests/RetryEndToEndTests.cs | 103 +++++++ 19 files changed, 606 insertions(+), 112 deletions(-) create mode 100644 src/StackExchange.Redis/Availability/RetryController.cs create mode 100644 src/StackExchange.Redis/Availability/RetryTransaction.cs create mode 100644 src/StackExchange.Redis/Interfaces/ITransactionAsync.cs diff --git a/eng/StackExchange.Redis.Build/AutoDatabaseGenerator.cs b/eng/StackExchange.Redis.Build/AutoDatabaseGenerator.cs index 356e726194..544d1c479a 100644 --- a/eng/StackExchange.Redis.Build/AutoDatabaseGenerator.cs +++ b/eng/StackExchange.Redis.Build/AutoDatabaseGenerator.cs @@ -199,6 +199,10 @@ private static bool SkipMethod(MethodInfo method) => !method.TypeArgs.IsEmpty || method.Name.Contains("Wait") || method.Name == "IsConnected" + // transaction/batch factories are not server round-trips and cannot be captured-and-replayed; + // the (few) databases that offer them implement them by hand + || method.Name == "CreateTransaction" + || method.Name == "CreateBatch" || method.ReturnType.StartsWith("System.Collections.Generic.IEnumerable<", StringComparison.Ordinal) || method.ReturnType.StartsWith("System.Collections.Generic.IAsyncEnumerable<", StringComparison.Ordinal); diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs index 7bfc180331..e896228c61 100644 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs @@ -40,6 +40,8 @@ public IBatch CreateBatch(object? asyncState = null) public ITransaction CreateTransaction(object? asyncState = null) => GetActiveDatabase().CreateTransaction(asyncState); + ITransactionAsync IDatabaseAsync.CreateTransaction(object? asyncState) => CreateTransaction(asyncState); + public void KeyMigrate(RedisKey key, System.Net.EndPoint toServer, int toDatabase = 0, int timeoutMilliseconds = 0, MigrateOptions migrateOptions = MigrateOptions.None, CommandFlags flags = CommandFlags.None) => GetActiveDatabase().KeyMigrate(key, toServer, toDatabase, timeoutMilliseconds, migrateOptions, flags); diff --git a/src/StackExchange.Redis/Availability/RetryController.cs b/src/StackExchange.Redis/Availability/RetryController.cs new file mode 100644 index 0000000000..a4b9fea6ac --- /dev/null +++ b/src/StackExchange.Redis/Availability/RetryController.cs @@ -0,0 +1,117 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using StackExchange.Redis.Interfaces; + +namespace StackExchange.Redis.Availability; + +/// +/// Holds the retry configuration derived from a (attempt counts, delays, +/// failover gating) and makes the per-attempt retry decision. This is shared by +/// and so the two paths apply identical policy math. +/// +internal sealed class RetryController +{ + private readonly int _maxBeforeFailover, _maxAttempts, _delayMillis, _jitterMillis, _failoverMillis; + private readonly RetryPolicy _policy; + + public RetryController(RetryPolicy policy, DatabaseFeatureFlags features) + { + _policy = policy; + + // capture config locally rather than constant cross-object lookups (plus: mutability) + _maxBeforeFailover = (features & DatabaseFeatureFlags.Failover) == 0 ? int.MaxValue : policy.MaxAttemptsBeforeFailover; + _maxAttempts = policy.MaxAttempts; + if (_maxBeforeFailover == _maxAttempts) _maxBeforeFailover = int.MaxValue; // then we'll never look + + // guard the failover threshold: values < 1 can never be hit by the loop counter (which starts at 1), + // so they would *silently* disable failover rather than erroring; validate the raw policy value + if (policy.MaxAttemptsBeforeFailover < 1) throw new ArgumentOutOfRangeException(nameof(policy.MaxAttemptsBeforeFailover)); + _delayMillis = policy.DelayMilliseconds; + _failoverMillis = policy.FailoverMilliseconds; + _jitterMillis = policy.JitterMilliseconds; + if (_delayMillis < 0) throw new ArgumentOutOfRangeException(nameof(policy.RetryDelay)); + if (_jitterMillis < 0) throw new ArgumentOutOfRangeException(nameof(policy.JitterMax)); + if (_failoverMillis < 0) throw new ArgumentOutOfRangeException(nameof(policy.FailoverDelay)); + } + + /// + /// Whether it is ever worth capturing the next-failover token: only when there is more than one + /// attempt and the failover threshold sits below the attempt cap. + /// + public bool TracksFailover => _maxAttempts > 1 & _maxBeforeFailover < _maxAttempts; + + public bool CanRetry( + int attempt, + Exception fault, + ref CancellationToken failover, + out CancellationToken delay) + { + delay = CancellationToken.None; + if (attempt >= _maxAttempts) + { + // all used up + return false; + } + + // ask the retry policy for advice, and mask off the bits we know about + FaultContext ctx = new(fault); + var policy = _policy.CanRetry(ctx) & + (RetryPolicy.RetryPolicyResult.FailoverServer | RetryPolicy.RetryPolicyResult.SameServer); + if (policy is 0) + { + // retry policy says: nope + return false; + } + + if (policy is RetryPolicy.RetryPolicyResult.FailoverServer) + { + // we can *only* retry on a different server; is failover available? + delay = failover; + failover = CancellationToken.None; // only failover once + return delay.CanBeCanceled; + } + + if (attempt == _maxBeforeFailover) + { + // by count, we should really switch over to the failover now; is failover available *and* are we allowed? + delay = failover; + failover = CancellationToken.None; // only failover once + return delay.CanBeCanceled & (policy & RetryPolicy.RetryPolicyResult.FailoverServer) != 0; + } + + // can we pause and retry on the same server? + return (policy & RetryPolicy.RetryPolicyResult.SameServer) != 0; + } + + public Task FailoverOrDelayAsync(CancellationToken delay) + { + if (delay.CanBeCanceled) + { + return AwaitFailover(delay); + } + + // this is just a routine wait between operations; await delay+jitter + return Task.Delay(_delayMillis + ServerSelectionStrategy.SharedRandom.Next(_jitterMillis), CancellationToken.None); + } + + private async Task AwaitFailover(CancellationToken failover) + { + if (!failover.IsCancellationRequested) + { + // failover hasn't happened yet; allow up to "delay" time for that + try + { + await Task.Delay(_failoverMillis, failover).ConfigureAwait(false); + } + catch (OperationCanceledException) when (failover.IsCancellationRequested) + { + // we observed a failover, nice! + } + } + + // either way, we need to add jitter onto that; we can't add in the original delay, because if the failover + // happened before the timeout+jitter, all the awaiters would stampede + await Task.Delay(ServerSelectionStrategy.SharedRandom.Next(_jitterMillis), CancellationToken.None).ConfigureAwait(false); + } +} diff --git a/src/StackExchange.Redis/Availability/RetryDatabase.cs b/src/StackExchange.Redis/Availability/RetryDatabase.cs index 8dfa132a18..ee97e030f7 100644 --- a/src/StackExchange.Redis/Availability/RetryDatabase.cs +++ b/src/StackExchange.Redis/Availability/RetryDatabase.cs @@ -21,11 +21,10 @@ DatabaseFeatureFlags IInternalDatabaseAsync.GetFeatures(out string name) public override string ToString() => this.BuildString(); private readonly IDatabaseAsync _inner; - private readonly int _maxBeforeFailover, _maxAttempts, _delayMillis, _jitterMillis, _failoverMillis; - private readonly RetryPolicy _policy; + private readonly RetryController _controller; public CancellationToken GetNextFailover() - => _maxAttempts > 1 & _maxBeforeFailover < _maxAttempts ? _inner.GetNextFailover() : CancellationToken.None; + => _controller.TracksFailover ? _inner.GetNextFailover() : CancellationToken.None; public RetryDatabase(IDatabaseAsync inner, RetryPolicy policy) // cannot nest retry, and cannot issue retries *inside* a batch/transaction @@ -38,25 +37,14 @@ public RetryDatabase(IDatabaseAsync inner, RetryPolicy policy) // null inner without a full IDatabaseAsync double. internal RetryDatabase(IDatabaseAsync inner, RetryPolicy policy, DatabaseFeatureFlags features) { - _policy = policy; - - // capture config locally rather than constant cross-object lookups (plus: mutability) - _maxBeforeFailover = (features & DatabaseFeatureFlags.Failover) == 0 ? int.MaxValue : policy.MaxAttemptsBeforeFailover; - _maxAttempts = policy.MaxAttempts; - if (_maxBeforeFailover == _maxAttempts) _maxBeforeFailover = int.MaxValue; // then we'll never look - - // guard the failover threshold: values < 1 can never be hit by the loop counter (which starts at 1), - // so they would *silently* disable failover rather than erroring; validate the raw policy value - if (policy.MaxAttemptsBeforeFailover < 1) throw new ArgumentOutOfRangeException(nameof(policy.MaxAttemptsBeforeFailover)); - _delayMillis = policy.DelayMilliseconds; - _failoverMillis = policy.FailoverMilliseconds; - _jitterMillis = policy.JitterMilliseconds; - if (_delayMillis < 0) throw new ArgumentOutOfRangeException(nameof(policy.RetryDelay)); - if (_jitterMillis < 0) throw new ArgumentOutOfRangeException(nameof(policy.JitterMax)); - if (_failoverMillis < 0) throw new ArgumentOutOfRangeException(nameof(policy.FailoverDelay)); + _controller = new RetryController(policy, features); _inner = inner; } + ITransactionAsync IDatabaseAsync.CreateTransaction(object? asyncState) + // the inner database creates the "real" (one-shot) transactions we replay against each attempt + => new RetryTransaction(_inner, _controller, asyncState); + public int Database => _inner.Database; public IConnectionMultiplexer Multiplexer => _inner.Multiplexer; @@ -84,9 +72,9 @@ private async Task ExecuteAsync(TState state, Func(TState state, Func= _maxAttempts) - { - // all used up - return false; - } - - // ask the retry policy for advice, and mask off the bits we know about - FaultContext ctx = new(fault); - var policy = _policy.CanRetry(ctx) & - (RetryPolicy.RetryPolicyResult.FailoverServer | RetryPolicy.RetryPolicyResult.SameServer); - if (policy is 0) - { - // retry policy says: nope - return false; - } - - if (policy is RetryPolicy.RetryPolicyResult.FailoverServer) - { - // we can *only* retry on a different server; is failover available? - delay = failover; - failover = CancellationToken.None; // only failover once - return delay.CanBeCanceled; - } - - if (attempt == _maxBeforeFailover) - { - // by count, we should really switch over to the failover now; is failover available *and* are we allowed? - delay = failover; - failover = CancellationToken.None; // only failover once - return delay.CanBeCanceled & (policy & RetryPolicy.RetryPolicyResult.FailoverServer) != 0; - } - - // can we pause and retry on the same server? - return (policy & RetryPolicy.RetryPolicyResult.SameServer) != 0; - } - - private Task FailoverOrDelayAsync(CancellationToken delay) - { - if (delay.CanBeCanceled) - { - return AwaitFailover(delay); - } - - // this is just a routine wait between operations; await delay+jitter - return Task.Delay(_delayMillis + ServerSelectionStrategy.SharedRandom.Next(_jitterMillis), CancellationToken.None); - } - private async Task AwaitFailover(CancellationToken failover) - { - if (!failover.IsCancellationRequested) - { - // failover hasn't happened yet; allow up to "delay" time for that - try - { - await Task.Delay(_failoverMillis, failover).ConfigureAwait(false); - } - catch (OperationCanceledException) when (failover.IsCancellationRequested) - { - // we observed a failover, nice! - } - } - - // either way, we need to add jitter onto that; we can't add in the original delay, because if the failover - // happened before the timeout+jitter, all the awaiters would stampede - await Task.Delay(ServerSelectionStrategy.SharedRandom.Next(_jitterMillis), CancellationToken.None).ConfigureAwait(false); - } - void IRedisAsync.Wait(Task task) => _inner.Wait(task); T IRedisAsync.Wait(Task task) => _inner.Wait(task); void IRedisAsync.WaitAll(Task[] tasks) => _inner.WaitAll(tasks); diff --git a/src/StackExchange.Redis/Availability/RetryTransaction.cs b/src/StackExchange.Redis/Availability/RetryTransaction.cs new file mode 100644 index 0000000000..a173608a68 --- /dev/null +++ b/src/StackExchange.Redis/Availability/RetryTransaction.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using StackExchange.Redis.Interfaces; + +namespace StackExchange.Redis.Availability; + +// A retryable transaction. Unlike RetryDatabase - which replays a single captured typed call each attempt - +// a transaction is built up across many builder calls plus an Execute, so we *record* each captured call +// (the AutoDatabase (state, projection) pair) and hand the caller a durable, still-incomplete proxy task. +// Only ExecuteAsync actually runs anything: for each attempt it spins up a fresh, one-shot inner transaction +// against the underlying database (which, for a multi-group connection, resolves the currently-active member, +// so a retry after failover lands on the new member), replays every recorded operation and constraint onto +// it, and awaits it. On a clean execution the per-attempt results are forwarded onto the durable proxies; on +// a transient fault the whole attempt is discarded and replayed; on terminal failure the proxies are faulted. +[AutoDatabase] +internal sealed partial class RetryTransaction : IDatabaseAsync, ITransactionAsync +{ + // Note: async-only, exactly like RetryDatabase - retrying is inherently delay-ish. + private readonly IDatabaseAsync _source; + private readonly object? _asyncState; + private readonly RetryController _controller; + + private readonly List _ops = new(); + private List? _conditions; + private int _executed; + + public RetryTransaction(IDatabaseAsync source, RetryController controller, object? asyncState) + { + _source = source; + _controller = controller; + _asyncState = asyncState; + } + + public int Database => _source.Database; + + public IConnectionMultiplexer Multiplexer => _source.Multiplexer; + + /// + public override string ToString() => "retry-transaction: " + _source; + + // the generated explicit interface implementations funnel every builder call through these two + // overloads, capturing the arguments in a generated state struct plus a cacheable static projection + // (no per-call closure). Here we simply *record* them and return a durable proxy task. + private Task ExecuteAsync(TState state, Func> operation) + where TState : struct, IRedisArgs + { + CheckNotExecuted(); + var op = new RecordedOp(state, operation); + _ops.Add(op); + return op.Proxy; + } + + private Task ExecuteAsync(TState state, Func operation) + where TState : struct, IRedisArgs + { + CheckNotExecuted(); + var op = new RecordedVoidOp(state, operation); + _ops.Add(op); + return op.Proxy; + } + + public ConditionResult AddCondition(Condition condition) + { + if (condition is null) throw new ArgumentNullException(nameof(condition)); + CheckNotExecuted(); + var recorded = new RecordedCondition(condition); + (_conditions ??= new List()).Add(recorded); + return recorded.Result; + } + + public async Task ExecuteAsync(CommandFlags flags = CommandFlags.None) + { + if (Interlocked.CompareExchange(ref _executed, 1, 0) != 0) + { + throw new InvalidOperationException("This transaction has already been executed"); + } + + int attempt = 0; + // capture the next-failover token *before* the first attempt - otherwise a failover between a + // failed attempt and re-reading the token could be missed + CancellationToken failover = _controller.TracksFailover ? _source.GetNextFailover() : CancellationToken.None; + + var conditions = _conditions; + while (true) + { + var inner = _source.CreateTransaction(_asyncState); + + // replay the recorded constraints and operations onto this fresh, one-shot transaction; the + // per-attempt tasks they return are forwarded to the durable proxies only on a clean execution + if (conditions is not null) + { + foreach (var c in conditions) c.Replay(inner); + } + foreach (var op in _ops) op.Replay(inner); + + // inject the aggregate retry category onto the EXEC flags so the resulting fault carries it, and + // the shared RetryPolicy/FaultContext logic gates the whole transaction exactly like one command + var category = inner is IInternalTransaction it ? it.GetAggregateRetryCategory() : CommandFlags.CommandRetryNever; + var effectiveFlags = (flags & ~Message.MaskRetryCategory) | category; + + try + { + bool committed = await inner.ExecuteAsync(effectiveFlags).ConfigureAwait(false); + + // clean completion (committed, or electively aborted via a failed WATCH); forward the + // per-attempt outcomes onto the durable proxies and we're done + if (conditions is not null) + { + foreach (var c in conditions) c.ForwardSuccess(); + } + foreach (var op in _ops) op.ForwardSuccess(); + return committed; + } + catch (Exception ex) + { + if (_controller.CanRetry(++attempt, ex, ref failover, out var delay)) + { + // discard this attempt; observe its faulted per-attempt tasks so they don't surface as + // unobserved, then wait / failover and replay from the recorded snapshot + foreach (var op in _ops) op.Observe(); + await _controller.FailoverOrDelayAsync(delay).ConfigureAwait(false); + continue; + } + + // out of road: fault the durable proxies and surface the failure to the caller + foreach (var op in _ops) op.Fault(ex); + throw; + } + } + } + + private void CheckNotExecuted() + { + if (Volatile.Read(ref _executed) != 0) + { + throw new InvalidOperationException("Operations cannot be added after the transaction has been executed"); + } + } + + // ---- recorded work ------------------------------------------------------------------------------- + private interface IRecordedOp + { + void Replay(IDatabaseAsync inner); + void ForwardSuccess(); + void Fault(Exception ex); + void Observe(); + } + + private sealed class RecordedOp : IRecordedOp + where TState : struct, IRedisArgs + { + private readonly TState _state; + private readonly Func> _operation; + private readonly TaskCompletionSource _proxy = new(TaskCreationOptions.RunContinuationsAsynchronously); + private Task? _attempt; + + public RecordedOp(TState state, Func> operation) + { + _state = state; + _operation = operation; + } + + public Task Proxy => _proxy.Task; + + public void Replay(IDatabaseAsync inner) => _attempt = _operation(_state, inner); + + public void ForwardSuccess() + { + var attempt = _attempt!; + if (attempt.IsCanceled) _proxy.TrySetCanceled(); + else if (attempt.IsFaulted) _proxy.TrySetException(attempt.Exception!.InnerExceptions); + else _proxy.TrySetResult(attempt.GetAwaiter().GetResult()); + } + + public void Fault(Exception ex) => _proxy.TrySetException(ex); + + public void Observe() => _ = _attempt?.Exception; + } + + private sealed class RecordedVoidOp : IRecordedOp + where TState : struct, IRedisArgs + { + private readonly TState _state; + private readonly Func _operation; + private readonly TaskCompletionSource _proxy = new(TaskCreationOptions.RunContinuationsAsynchronously); + private Task? _attempt; + + public RecordedVoidOp(TState state, Func operation) + { + _state = state; + _operation = operation; + } + + public Task Proxy => _proxy.Task; + + public void Replay(IDatabaseAsync inner) => _attempt = _operation(_state, inner); + + public void ForwardSuccess() + { + var attempt = _attempt!; + if (attempt.IsCanceled) _proxy.TrySetCanceled(); + else if (attempt.IsFaulted) _proxy.TrySetException(attempt.Exception!.InnerExceptions); + else _proxy.TrySetResult(true); + } + + public void Fault(Exception ex) => _proxy.TrySetException(ex); + + public void Observe() => _ = _attempt?.Exception; + } + + private sealed class RecordedCondition + { + private readonly Condition _condition; + private ConditionResult? _attempt; + + public RecordedCondition(Condition condition) + { + _condition = condition; + Result = new ConditionResult(condition); + } + + // the durable result handed back to the caller from AddCondition + public ConditionResult Result { get; } + + public void Replay(ITransactionAsync inner) => _attempt = inner.AddCondition(_condition); + + public void ForwardSuccess() + { + if (_attempt is not null) Result.SetSatisfied(_attempt.WasSatisfied); + } + } + + // ---- hand-implemented members the generator deliberately skips ---------------------------------- + // (the Wait family, the synchronous IsConnected probe, and the streaming scans). Wait/IsConnected are + // straight pass-throughs; scans cannot participate in a transaction. + void IRedisAsync.Wait(Task task) => _source.Wait(task); + T IRedisAsync.Wait(Task task) => _source.Wait(task); + void IRedisAsync.WaitAll(Task[] tasks) => _source.WaitAll(tasks); + bool IRedisAsync.TryWait(Task task) => _source.TryWait(task); + + bool IDatabaseAsync.IsConnected(RedisKey key, CommandFlags flags) => _source.IsConnected(key, flags); + + // nested transactions are not supported (mirrors RedisTransaction) + ITransactionAsync IDatabaseAsync.CreateTransaction(object? asyncState) => throw new NotSupportedException("Nested transactions are not supported"); + + IAsyncEnumerable IDatabaseAsync.HashScanAsync(RedisKey key, RedisValue pattern, int pageSize, long cursor, int pageOffset, CommandFlags flags) => throw new NotSupportedException("Scans cannot be used inside a transaction"); + IAsyncEnumerable IDatabaseAsync.HashScanNoValuesAsync(RedisKey key, RedisValue pattern, int pageSize, long cursor, int pageOffset, CommandFlags flags) => throw new NotSupportedException("Scans cannot be used inside a transaction"); + IAsyncEnumerable IDatabaseAsync.SetScanAsync(RedisKey key, RedisValue pattern, int pageSize, long cursor, int pageOffset, CommandFlags flags) => throw new NotSupportedException("Scans cannot be used inside a transaction"); + IAsyncEnumerable IDatabaseAsync.VectorSetRangeEnumerateAsync(RedisKey key, RedisValue start, RedisValue end, long count, Exclude exclude, CommandFlags flags) => throw new NotSupportedException("Scans cannot be used inside a transaction"); + IAsyncEnumerable IDatabaseAsync.SortedSetScanAsync(RedisKey key, RedisValue pattern, int pageSize, long cursor, int pageOffset, CommandFlags flags) => throw new NotSupportedException("Scans cannot be used inside a transaction"); +} diff --git a/src/StackExchange.Redis/Condition.cs b/src/StackExchange.Redis/Condition.cs index fa7e762994..daa9979c10 100644 --- a/src/StackExchange.Redis/Condition.cs +++ b/src/StackExchange.Redis/Condition.cs @@ -953,6 +953,11 @@ internal ConditionResult(Condition condition) internal IEnumerable CreateMessages(int db) => Condition.CreateMessages(db, resultBox); internal IResultBox? GetBox() => resultBox; + + // used by the retry machinery to copy the outcome of a per-attempt condition onto the durable + // ConditionResult that was handed back to the caller when the transaction was built + internal void SetSatisfied(bool value) => wasSatisfied = value; + internal bool UnwrapBox() { if (resultBox != null) diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.cs b/src/StackExchange.Redis/Interfaces/IDatabase.cs index dbaa70d428..83e9f06849 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabase.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs @@ -32,7 +32,8 @@ public partial interface IDatabase : IRedis, IDatabaseAsync /// /// The async object state to be passed into the created . /// The created transaction. - ITransaction CreateTransaction(object? asyncState = null); + // hides IDatabaseAsync.CreateTransaction, refining the return type from ITransactionAsync to ITransaction + new ITransaction CreateTransaction(object? asyncState = null); /// /// Atomically transfer a key from a source Redis instance to a destination Redis instance. diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs index b7f43848bd..bf76427659 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs @@ -17,6 +17,16 @@ public partial interface IDatabaseAsync : IRedisAsync /// int Database { get; } + /// + /// Creates a transaction exposing only asynchronous completion; see . + /// + /// The async state to set on the created transaction. + /// Unlike , this offers no synchronous + /// execution, so it is usable from async-only databases such as one created via + /// . + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + ITransactionAsync CreateTransaction(object? asyncState = null); + /// /// Indicates whether the instance can communicate with the server (resolved using the supplied key and optional flags). /// diff --git a/src/StackExchange.Redis/Interfaces/IInternalDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IInternalDatabaseAsync.cs index 7ac83afda0..6abc0505ad 100644 --- a/src/StackExchange.Redis/Interfaces/IInternalDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IInternalDatabaseAsync.cs @@ -23,6 +23,19 @@ internal interface IInternalDatabaseAsync : IDatabaseAsync CancellationToken GetNextFailover(); } +/// +/// Exposes transaction-level detail needed by the retry machinery. Implemented by the concrete +/// transaction types so a RetryTransaction can inspect the transaction it is replaying against. +/// +internal interface IInternalTransaction +{ + /// + /// The most side-effecting retry category across all queued operations (excluding WATCH + /// constraints); this describes what replaying the whole transaction would do. + /// + CommandFlags GetAggregateRetryCategory(); +} + internal static class InternalDatabaseExtension { internal static DatabaseFeatureFlags GetFeatures(this IDatabaseAsync database, out string name) diff --git a/src/StackExchange.Redis/Interfaces/ITransaction.cs b/src/StackExchange.Redis/Interfaces/ITransaction.cs index 21c66968ae..d8fdaf4c72 100644 --- a/src/StackExchange.Redis/Interfaces/ITransaction.cs +++ b/src/StackExchange.Redis/Interfaces/ITransaction.cs @@ -14,13 +14,15 @@ namespace StackExchange.Redis /// Note that on a cluster, it may be required that all keys involved in the transaction (including constraints) are in the same hash-slot. /// /// - public interface ITransaction : IBatch + public interface ITransaction : IBatch, ITransactionAsync { /// /// Adds a precondition for this transaction. /// /// The condition to add to the transaction. - ConditionResult AddCondition(Condition condition); + // re-declared (rather than inherited from ITransactionAsync) so this interface's surface is + // self-contained and unambiguous for existing callers + new ConditionResult AddCondition(Condition condition); /// /// Execute the batch operation, sending all queued commands to the server. @@ -32,6 +34,6 @@ public interface ITransaction : IBatch /// Execute the batch operation, sending all queued commands to the server. /// /// The command flags to use. - Task ExecuteAsync(CommandFlags flags = CommandFlags.None); + new Task ExecuteAsync(CommandFlags flags = CommandFlags.None); } } diff --git a/src/StackExchange.Redis/Interfaces/ITransactionAsync.cs b/src/StackExchange.Redis/Interfaces/ITransactionAsync.cs new file mode 100644 index 0000000000..a9c840dc19 --- /dev/null +++ b/src/StackExchange.Redis/Interfaces/ITransactionAsync.cs @@ -0,0 +1,29 @@ +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +/// +/// Represents a group of operations that will be sent to the server as a single unit, +/// and processed on the server as a single unit, exposing only asynchronous completion. +/// This is the async-only counterpart to , used where synchronous +/// execution is not offered - for example, a retrying database created via +/// , where execution may inherently involve delays. +/// +/// +/// Transactions can also include constraints (implemented via WATCH). +/// +/// +public interface ITransactionAsync : IDatabaseAsync +{ + /// + /// Adds a precondition for this transaction. + /// + /// The condition to add to the transaction. + ConditionResult AddCondition(Condition condition); + + /// + /// Execute the transaction, sending all queued commands to the server. + /// + /// The command flags to use. + Task ExecuteAsync(CommandFlags flags = CommandFlags.None); +} diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs index 877ffb0b56..81d07d7bd8 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs @@ -21,6 +21,11 @@ internal KeyPrefixed(TInner inner, byte[] keyPrefix) public int Database => Inner.Database; + // default: this wrapper is not a full database (it is the base for the batch/transaction wrappers too), + // so it cannot start a transaction; KeyPrefixedDatabase reimplements this to prefix a real one + ITransactionAsync IDatabaseAsync.CreateTransaction(object? asyncState) + => throw new NotSupportedException("Transactions cannot be created here"); + internal TInner Inner { get; } internal byte[] Prefix { get; } diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index 50422bcf90..7d0b05effe 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -25,6 +25,9 @@ public ITransaction CreateTransaction(object? asyncState = null) return new KeyPrefixedTransaction(Inner.CreateTransaction(asyncState), Prefix); } + // reimplement the async-only IDatabaseAsync slot (the base throws); we prefix a real transaction + ITransactionAsync IDatabaseAsync.CreateTransaction(object? asyncState) => CreateTransaction(asyncState); + public RedisValue DebugObject(RedisKey key, CommandFlags flags = CommandFlags.None) => Inner.DebugObject(ToInner(key), flags); diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedTransaction.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedTransaction.cs index 139f502674..78e1a133a6 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedTransaction.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedTransaction.cs @@ -3,7 +3,7 @@ namespace StackExchange.Redis.KeyspaceIsolation { - internal sealed class KeyPrefixedTransaction : KeyPrefixed, ITransaction + internal sealed class KeyPrefixedTransaction : KeyPrefixed, ITransaction, IInternalTransaction { public KeyPrefixedTransaction(ITransaction inner, byte[] prefix) : base(inner, prefix) { @@ -12,6 +12,9 @@ public KeyPrefixedTransaction(ITransaction inner, byte[] prefix) : base(inner, p private protected override DatabaseFeatureFlags GetDatabaseFeatures() => base.GetDatabaseFeatures() | DatabaseFeatureFlags.Transaction; + CommandFlags IInternalTransaction.GetAggregateRetryCategory() + => Inner is IInternalTransaction it ? it.GetAggregateRetryCategory() : CommandFlags.CommandRetryNever; + public ConditionResult AddCondition(Condition condition) => Inner.AddCondition(condition.MapKeys(GetMapFunction())); public bool Execute(CommandFlags flags = CommandFlags.None) => Inner.Execute(flags); diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 47cf16bcf0..442ae95a30 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -17,6 +17,10 @@ StackExchange.Redis.IDatabaseAsync.Database.get -> int StackExchange.Redis.IServer.InventKey(StackExchange.Redis.RedisKey prefix = default(StackExchange.Redis.RedisKey)) -> StackExchange.Redis.RedisKey StackExchange.Redis.Availability.DatabaseExtensions [SER007]static StackExchange.Redis.Availability.DatabaseExtensions.WithRetry(this StackExchange.Redis.IDatabaseAsync! database, StackExchange.Redis.Availability.RetryPolicy! retryPolicy) -> StackExchange.Redis.IDatabaseAsync! +[SER007]StackExchange.Redis.IDatabaseAsync.CreateTransaction(object? asyncState = null) -> StackExchange.Redis.ITransactionAsync! +StackExchange.Redis.ITransactionAsync +StackExchange.Redis.ITransactionAsync.AddCondition(StackExchange.Redis.Condition! condition) -> StackExchange.Redis.ConditionResult! +StackExchange.Redis.ITransactionAsync.ExecuteAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! [SER007]StackExchange.Redis.Availability.CircuitBreaker [SER007]StackExchange.Redis.Availability.CircuitBreaker.Accumulator [SER007]StackExchange.Redis.Availability.CircuitBreaker.Accumulator.Accumulator() -> void diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index aadaf5abe6..aa7382fb58 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -49,6 +49,10 @@ public ITransaction CreateTransaction(object? asyncState) return new RedisTransaction(this, asyncState); } + // the async-only IDatabaseAsync slot; the return type differs (ITransactionAsync vs ITransaction), + // so it does not implicitly bind to the public method above + ITransactionAsync IDatabaseAsync.CreateTransaction(object? asyncState) => CreateTransaction(asyncState); + private ITransaction? CreateTransactionIfAvailable(object? asyncState) { var map = multiplexer.CommandMap; diff --git a/src/StackExchange.Redis/RedisTransaction.cs b/src/StackExchange.Redis/RedisTransaction.cs index c1c39b3c1a..0fc790440f 100644 --- a/src/StackExchange.Redis/RedisTransaction.cs +++ b/src/StackExchange.Redis/RedisTransaction.cs @@ -9,12 +9,33 @@ namespace StackExchange.Redis { - internal sealed class RedisTransaction : RedisDatabase, ITransaction + internal sealed class RedisTransaction : RedisDatabase, ITransaction, IInternalTransaction { private List? _conditions; private List? _pending; private object SyncLock => this; + // combine the retry categories of all queued operations, taking the most side-effecting (numerically + // highest) - this is what a replay of the whole transaction would do. WATCH constraints are *not* + // included: they live in _conditions, not _pending, and re-issuing them is what makes replay safe. + CommandFlags IInternalTransaction.GetAggregateRetryCategory() + { + var result = CommandFlags.None; + lock (SyncLock) + { + var list = _pending; + if (list != null) + { + foreach (var q in list) + { + var cat = q.Wrapped.Flags & Message.MaskRetryCategory; + if (cat > result) result = cat; + } + } + } + return result; + } + private protected override DatabaseFeatureFlags GetDatabaseFeatures() => base.GetDatabaseFeatures() | DatabaseFeatureFlags.Transaction; diff --git a/tests/StackExchange.Redis.Tests/RetryTests/CommandRetryPolicyUnitTests.cs b/tests/StackExchange.Redis.Tests/RetryTests/CommandRetryPolicyUnitTests.cs index b8e630da13..0a7dbe6576 100644 --- a/tests/StackExchange.Redis.Tests/RetryTests/CommandRetryPolicyUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/RetryTests/CommandRetryPolicyUnitTests.cs @@ -108,14 +108,14 @@ public void CanRetry_ServerSpecificDoesNotAffectRange(CommandFlags category, boo public void RetryDatabase_CanRetry_MaxAttempts_NoFailover(int attempt, bool expected) { var policy = new RetryPolicy { MaxAttempts = 3, MaxAttemptsBeforeFailover = 3 }; - var db = new RetryDatabase(null!, policy); // CanRetry never touches the inner database + var controller = new RetryController(policy, DatabaseFeatureFlags.None); // CanRetry never touches any database using var cts = new CancellationTokenSource(); var token = cts.Token; var failover = token; var fault = new RedisServerException(RedisErrorKind.Loading, CommandFlags.CommandRetryWriteLastWins, "LOADING"); - var result = db.CanRetry(attempt, fault, ref failover, out var delay); + var result = controller.CanRetry(attempt, fault, ref failover, out var delay); Assert.Equal(expected, result); Assert.False(delay.CanBeCanceled); // never waiting for a failover @@ -132,7 +132,7 @@ public void RetryDatabase_CanRetry_FailoverAtThreshold() { var policy = new RetryPolicy { MaxAttempts = 4, MaxAttemptsBeforeFailover = 2 }; // failover is only armed when the inner database advertises the feature; supply it explicitly - var db = new RetryDatabase(null!, policy, DatabaseFeatureFlags.Failover); + var controller = new RetryController(policy, DatabaseFeatureFlags.Failover); using var cts = new CancellationTokenSource(); var token = cts.Token; @@ -140,24 +140,24 @@ public void RetryDatabase_CanRetry_FailoverAtThreshold() var fault = new RedisServerException(RedisErrorKind.Loading, CommandFlags.CommandRetryWriteLastWins, "LOADING"); // attempt 1: plain same-server retry; failover token not yet consumed - Assert.True(db.CanRetry(1, fault, ref failover, out var delay)); + Assert.True(controller.CanRetry(1, fault, ref failover, out var delay)); Assert.False(delay.CanBeCanceled); Assert.Equal(token, failover); // attempt 2 (== MaxAttemptsBeforeFailover): still a retry, but now fail over - "delay" becomes the // failover token and the ref is cleared to None so it only fires once - Assert.True(db.CanRetry(2, fault, ref failover, out delay)); + Assert.True(controller.CanRetry(2, fault, ref failover, out delay)); Assert.Equal(token, delay); Assert.True(delay.CanBeCanceled); Assert.Equal(CancellationToken.None, failover); // attempt 3: failover already spent (ref is None) -> back to a same-server retry - Assert.True(db.CanRetry(3, fault, ref failover, out delay)); + Assert.True(controller.CanRetry(3, fault, ref failover, out delay)); Assert.False(delay.CanBeCanceled); Assert.Equal(CancellationToken.None, failover); // attempt 4: no retries left - Assert.False(db.CanRetry(4, fault, ref failover, out delay)); + Assert.False(controller.CanRetry(4, fault, ref failover, out delay)); Assert.False(delay.CanBeCanceled); } @@ -170,7 +170,7 @@ public void RetryDatabase_CanRetry_FailoverAtThreshold() public void RetryDatabase_CanRetry_ServerSpecific_CannotFailover() { var policy = new RetryPolicy { MaxAttempts = 4, MaxAttemptsBeforeFailover = 2 }; - var db = new RetryDatabase(null!, policy, DatabaseFeatureFlags.Failover); + var controller = new RetryController(policy, DatabaseFeatureFlags.Failover); using var cts = new CancellationTokenSource(); var token = cts.Token; @@ -179,13 +179,13 @@ public void RetryDatabase_CanRetry_ServerSpecific_CannotFailover() var fault = new RedisServerException(RedisErrorKind.Loading, flags, "LOADING"); // attempt 1: same-server retry; failover token untouched - Assert.True(db.CanRetry(1, fault, ref failover, out var delay)); + Assert.True(controller.CanRetry(1, fault, ref failover, out var delay)); Assert.False(delay.CanBeCanceled); Assert.Equal(token, failover); // attempt 2 (== MaxAttemptsBeforeFailover): sticky forbids failover -> gives up (false), even though // attempts remain; the failover token is still consumed to None as a side-effect of the branch - Assert.False(db.CanRetry(2, fault, ref failover, out delay)); + Assert.False(controller.CanRetry(2, fault, ref failover, out delay)); Assert.Equal(CancellationToken.None, failover); } } diff --git a/tests/StackExchange.Redis.Tests/RetryTests/RetryEndToEndTests.cs b/tests/StackExchange.Redis.Tests/RetryTests/RetryEndToEndTests.cs index e2b9583f7b..635fbc8d2e 100644 --- a/tests/StackExchange.Redis.Tests/RetryTests/RetryEndToEndTests.cs +++ b/tests/StackExchange.Redis.Tests/RetryTests/RetryEndToEndTests.cs @@ -125,6 +125,109 @@ public async Task WithRetry_FailsOverBetweenGroupsOnLoading() Assert.Same(members[1], conn.ActiveMember); // we really did move to B } + // End-to-end for a retryable transaction: a server that fails the first EXEC with a transient LOADING + // error (discarding that attempt's queued commands), then commits the second. A transaction created via + // the retrying database should ride this out: the per-operation tasks handed out at build time resolve + // from the winning (second) attempt, the value is actually written, and the server saw two EXECs. + [Fact] + public async Task WithRetry_Transaction_RidesOutTransientExec() + { + using var server = new ExecFailServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + Assert.True(conn.IsConnected); + + var db = conn.GetDatabase(); + + RedisKey key = "retry:tran"; + Assert.True(await db.StringSetAsync(key, "seed")); // seed a value we expect the transaction to overwrite + + server.FailExecOps = 1; // fail the first EXEC; the second should commit + + var policy = new RetryPolicy + { + MaxAttempts = 3, + RetryDelay = TimeSpan.Zero, + JitterMax = TimeSpan.Zero, + }; + var retryDb = db.WithRetry(policy); + + var tran = retryDb.CreateTransaction(); + var setTask = tran.StringSetAsync(key, "committed"); + var getTask = tran.StringGetAsync(key); + bool committed = await tran.ExecuteAsync(); + + Assert.True(committed); // rode out the transient EXEC failure + Assert.True(await setTask); // per-op proxy resolved from the winning attempt + Assert.Equal("committed", await getTask); // read reflects the committed value + Assert.Equal("committed", await db.StringGetAsync(key)); // and it really landed on the server + Assert.Equal(0, server.FailExecOps); // the transient failure was consumed + Assert.Equal(2, server.ExecOpsReceived); // 1 x LOADING + 1 x commit + } + + // The transaction's effective retry category is the most side-effecting of its operations. An INCR makes + // the whole transaction "accumulating", which the default policy (capped at write-last-wins) refuses to + // retry - so a transient EXEC failure surfaces and the per-op proxy faults rather than hanging. Raising + // the cap to allow accumulating writes lets the same transaction ride the failure out; and because the + // failed attempt is discarded server-side, the INCR applies exactly once (no double-count). + [Fact] + public async Task WithRetry_Transaction_AccumulatingOp_RespectsCategoryGate() + { + using var server = new ExecFailServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + Assert.True(conn.IsConnected); + + var db = conn.GetDatabase(); + RedisKey key = "retry:tran:incr"; + + // default cap = write-last-wins; an INCR makes the aggregate accumulating -> NOT retried + var conservative = new RetryPolicy { MaxAttempts = 3, RetryDelay = TimeSpan.Zero, JitterMax = TimeSpan.Zero }; + server.FailExecOps = 1; + var tran1 = db.WithRetry(conservative).CreateTransaction(); + var incr1 = tran1.StringIncrementAsync(key); + await Assert.ThrowsAsync(async () => await tran1.ExecuteAsync()); + await Assert.ThrowsAsync(async () => await incr1); // proxy faulted, not left hanging + Assert.Equal(1, server.ExecOpsReceived); // gave up immediately, no retry + Assert.Equal(0, server.FailExecOps); + + // raise the cap to allow accumulating writes: the same transaction now rides out the transient failure + var permissive = new RetryPolicy + { + MaxAttempts = 3, + RetryDelay = TimeSpan.Zero, + JitterMax = TimeSpan.Zero, + MaxCommandRetryCategory = CommandFlags.CommandRetryWriteAccumulating, + }; + server.FailExecOps = 1; + var tran2 = db.WithRetry(permissive).CreateTransaction(); + var incr2 = tran2.StringIncrementAsync(key); + Assert.True(await tran2.ExecuteAsync()); + Assert.Equal(1, await incr2); // discarded attempt did NOT apply -> incremented exactly once + Assert.Equal(3, server.ExecOpsReceived); // 1 (first block) + 2 (LOADING + commit) + } + + // An in-proc server that fails the first FailExecOps EXEC operations with a transient LOADING error, + // discarding that attempt's queued commands so nothing is applied, then commits normally. + private sealed class ExecFailServer(ITestOutputHelper? log) : InProcessTestServer(log) + { + public int ExecOpsReceived { get; private set; } + + public int FailExecOps { get; set; } + + protected override TypedRedisValue Exec(RedisClient client, in RedisRequest request) + { + ExecOpsReceived++; + + if (FailExecOps > 0) + { + FailExecOps--; + client.Discard(); // drop this attempt's queued commands; nothing is applied + return TypedRedisValue.Error("LOADING Redis is loading the dataset in memory"); + } + + return base.Exec(client, in request); + } + } + // An in-proc server that fails the first LoadingOps GET operations with a transient LOADING error // (decrementing the counter each time), then serves normally. Every GET bumps GetOpsReceived so the // test can confirm how many attempts actually reached the server. From eed811aff1b4cf6aebf08971eed90491d01d8552 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 28 Jul 2026 14:41:25 +0100 Subject: [PATCH 22/33] observe transaction faults --- docs/ActiveActive.md | 4 +++- .../Availability/RetryTransaction.cs | 16 ++++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/ActiveActive.md b/docs/ActiveActive.md index aab277ce89..80f30c300b 100644 --- a/docs/ActiveActive.md +++ b/docs/ActiveActive.md @@ -475,7 +475,9 @@ IDatabaseAsync db = conn.GetDatabase().WithRetry(new RetryPolicy()); var value = await db.StringGetAsync("mykey"); ``` -> You can call `WithRetry` on any database (`IDatabase` or `IDatabaseAsync`), but the wrapper it returns exposes only the **async** API — there is no synchronous form, since retrying may inherently have delays. It cannot wrap a batch or transaction, nor an already-retrying database. +> You can call `WithRetry` on any database (`IDatabase` or `IDatabaseAsync`), but the wrapper it returns exposes only the **async** API — there is no synchronous form, since retrying may inherently have delays. It cannot wrap a batch or an existing transaction, nor an already-retrying database. + +A retrying database can still *create* a transaction: `retryDb.CreateTransaction()` returns an `ITransactionAsync` whose `ExecuteAsync` is retried as a single unit. Each attempt replays the queued operations (and any `WATCH` constraints) against a fresh `MULTI`/`EXEC` - and, in an Active:Active group, onto whichever member is active at the time - so a transaction can ride out a failover just like a single command; the per-operation tasks handed back at build time resolve from the winning attempt. The retry-category gate (below) still applies, using the *most* side-effecting operation in the transaction: a transaction containing an `INCR` is treated as `CommandRetryWriteAccumulating`, so the default policy will not retry it unless you raise `MaxCommandRetryCategory`. ### RetryPolicy settings diff --git a/src/StackExchange.Redis/Availability/RetryTransaction.cs b/src/StackExchange.Redis/Availability/RetryTransaction.cs index a173608a68..592bc24f90 100644 --- a/src/StackExchange.Redis/Availability/RetryTransaction.cs +++ b/src/StackExchange.Redis/Availability/RetryTransaction.cs @@ -174,7 +174,13 @@ public void ForwardSuccess() else _proxy.TrySetResult(attempt.GetAwaiter().GetResult()); } - public void Fault(Exception ex) => _proxy.TrySetException(ex); + // observe the (faulted) inner attempt before faulting the durable proxy, so the discarded + // per-attempt task doesn't surface as an unobserved exception + public void Fault(Exception ex) + { + Observe(); + _proxy.TrySetException(ex); + } public void Observe() => _ = _attempt?.Exception; } @@ -205,7 +211,13 @@ public void ForwardSuccess() else _proxy.TrySetResult(true); } - public void Fault(Exception ex) => _proxy.TrySetException(ex); + // observe the (faulted) inner attempt before faulting the durable proxy, so the discarded + // per-attempt task doesn't surface as an unobserved exception + public void Fault(Exception ex) + { + Observe(); + _proxy.TrySetException(ex); + } public void Observe() => _ = _attempt?.Exception; } From d86ef4bb3d9dd235a66239a048a760e642398b94 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 28 Jul 2026 14:54:13 +0100 Subject: [PATCH 23/33] more transaction tests --- .../RetryTests/RetryEndToEndTests.cs | 169 +++++++++++++++++- 1 file changed, 168 insertions(+), 1 deletion(-) diff --git a/tests/StackExchange.Redis.Tests/RetryTests/RetryEndToEndTests.cs b/tests/StackExchange.Redis.Tests/RetryTests/RetryEndToEndTests.cs index 635fbc8d2e..b7b2202de2 100644 --- a/tests/StackExchange.Redis.Tests/RetryTests/RetryEndToEndTests.cs +++ b/tests/StackExchange.Redis.Tests/RetryTests/RetryEndToEndTests.cs @@ -205,9 +205,176 @@ public async Task WithRetry_Transaction_AccumulatingOp_RespectsCategoryGate() Assert.Equal(3, server.ExecOpsReceived); // 1 (first block) + 2 (LOADING + commit) } + // A WATCH constraint is replayed on every attempt. Here the condition is satisfied, and the first EXEC + // hits a transient LOADING; the transaction should ride it out and the *durable* ConditionResult handed + // back at build time should reflect the winning attempt (satisfied). Confirms the condition survives the + // discard+replay and that its outcome is forwarded onto the caller-visible result. + [Fact] + public async Task WithRetry_Transaction_SatisfiedCondition_RidesOutTransientExec() + { + using var server = new ExecFailServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + Assert.True(conn.IsConnected); + + var db = conn.GetDatabase(); + RedisKey key = "retry:tran:cond"; + Assert.True(await db.StringSetAsync(key, "seed")); + + server.FailExecOps = 1; // fail the first EXEC; the condition must still hold on the replay + + var policy = new RetryPolicy { MaxAttempts = 3, RetryDelay = TimeSpan.Zero, JitterMax = TimeSpan.Zero }; + var tran = db.WithRetry(policy).CreateTransaction(); + var cond = tran.AddCondition(Condition.StringEqual(key, "seed")); // satisfied on both attempts + var setTask = tran.StringSetAsync(key, "committed"); + bool committed = await tran.ExecuteAsync(); + + Assert.True(committed); // rode out the transient EXEC failure + Assert.True(cond.WasSatisfied); // durable condition result forwarded from the winning attempt + Assert.True(await setTask); // per-op proxy resolved + Assert.Equal("committed", await db.StringGetAsync(key)); // the write really landed + Assert.Equal(2, server.ExecOpsReceived); // 1 x LOADING + 1 x commit -> the WATCH replayed + } + + // A WATCH constraint that is *not* satisfied is a business outcome, not a transient fault: the transaction + // aborts electively (no EXEC is ever issued), the per-operation proxies are cancelled, and - crucially - + // it is NOT retried. Confirms the ForwardSuccess cancellation branch and that a failed WATCH doesn't loop. + [Fact] + public async Task WithRetry_Transaction_UnsatisfiedCondition_AbortsWithoutRetry() + { + using var server = new ExecFailServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + Assert.True(conn.IsConnected); + + var db = conn.GetDatabase(); + RedisKey key = "retry:tran:cond:fail"; + Assert.True(await db.StringSetAsync(key, "seed")); + + server.FailExecOps = 0; // no transient fault; the condition itself aborts the transaction + + var policy = new RetryPolicy { MaxAttempts = 3, RetryDelay = TimeSpan.Zero, JitterMax = TimeSpan.Zero }; + var tran = db.WithRetry(policy).CreateTransaction(); + var cond = tran.AddCondition(Condition.StringEqual(key, "different")); // NOT satisfied + var setTask = tran.StringSetAsync(key, "committed"); + bool committed = await tran.ExecuteAsync(); + + Assert.False(committed); // electively aborted via the failed WATCH + Assert.False(cond.WasSatisfied); + await Assert.ThrowsAnyAsync(async () => await setTask); // proxy cancelled, not hung + Assert.Equal("seed", await db.StringGetAsync(key)); // nothing was written + Assert.Equal(0, server.ExecOpsReceived); // never EXEC'd, and (the point) never retried + } + + // A committed transaction can still carry a per-command error (e.g. INCR against a non-numeric string + // errors while EXEC itself succeeds). committed is true, the good op resolves, and only the offending op's + // proxy faults - exercising the ForwardSuccess faulted branch, which the transient-EXEC tests never hit. + [Fact] + public async Task WithRetry_Transaction_PerOpError_FaultsOnlyThatProxy() + { + using var server = new ExecFailServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + Assert.True(conn.IsConnected); + + var db = conn.GetDatabase(); + RedisKey badKey = "retry:tran:notnum"; + RedisKey goodKey = "retry:tran:str"; + Assert.True(await db.StringSetAsync(badKey, "abc")); // non-numeric: INCR will error at EXEC time + + server.FailExecOps = 0; // EXEC commits; one queued op errors at execution time + + // allow accumulating so the INCR doesn't gate retries - though nothing here retries anyway (EXEC commits) + var policy = new RetryPolicy + { + MaxAttempts = 3, + RetryDelay = TimeSpan.Zero, + JitterMax = TimeSpan.Zero, + MaxCommandRetryCategory = CommandFlags.CommandRetryWriteAccumulating, + }; + var tran = db.WithRetry(policy).CreateTransaction(); + var badTask = tran.StringIncrementAsync(badKey); // errors at EXEC: INCR on a non-numeric value + var goodTask = tran.StringSetAsync(goodKey, "ok"); + bool committed = await tran.ExecuteAsync(); + + Assert.True(committed); // EXEC still committed + Assert.True(await goodTask); // the good op resolved + await Assert.ThrowsAsync(async () => await badTask); // only the bad op faulted + Assert.Equal("ok", await db.StringGetAsync(goodKey)); // the good write landed + Assert.Equal(1, server.ExecOpsReceived); // committed first time; no retry + } + + // Multi-group + a retryable transaction: A fails every EXEC and is knocked out, so the group reroutes to B. + // Because RetryTransaction builds a *fresh* inner transaction against the currently-active member on each + // attempt, the replay lands on B and commits there - the transaction analogue of the single-command + // WithRetry_FailsOverBetweenGroupsOnLoading test. + [Fact] + public async Task WithRetry_Transaction_FailsOverBetweenGroups() + { + EndPoint alpha = new DnsEndPoint("alpha", 6379); + EndPoint bravo = new DnsEndPoint("bravo", 6379); + using var serverA = new ExecFailServer(Output, endpoint: alpha); // EXEC always fails on A + using var serverB = new InProcessTestServer(Output, endpoint: bravo); + + RedisKey key = "retry:tran:failover"; + + var probe = new ControllableProbe(); + + // A carries a hair-trigger breaker (trips on the first fault); B keeps the default + var configA = serverA.GetClientConfig(); + configA.CircuitBreaker = new CircuitBreaker.Builder + { + MinimumNumberOfFailures = 1, + FailureRateThreshold = 1, + }; + + ConnectionGroupMember[] members = + [ + new(configA, "A") { Weight = 9 }, // highest weight -> initially active + new(serverB.GetClientConfig(), "B") { Weight = 1 }, // failover target + ]; + + var options = new MultiGroupOptions + { + HealthCheck = new HealthCheck + { + Probe = probe, + Interval = TimeSpan.FromMinutes(30), // huge: the breaker fast-path is what reroutes us + ProbeCount = 1, + ProbeTimeout = TimeSpan.FromSeconds(5), + }, + }; + + await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members, options); + Assert.True(conn.IsConnected); + Assert.Same(members[0], conn.ActiveMember); // A is active (highest weight) + + var policy = new RetryPolicy + { + MaxAttempts = 20, + MaxAttemptsBeforeFailover = 1, + RetryDelay = TimeSpan.Zero, + JitterMax = TimeSpan.Zero, + }; + var db = conn.GetDatabase().WithRetry(policy); + + // A will fail every EXEC; knock it out so the group reroutes to B + serverA.FailExecOps = int.MaxValue; + probe.MarkDown(alpha); + + var tran = db.CreateTransaction(); + var setTask = tran.StringSetAsync(key, "committed-on-B"); + bool committed = await tran.ExecuteAsync(); + + Assert.True(committed); // rode the failover across to B and committed there + Assert.True(await setTask); + Assert.Same(members[1], conn.ActiveMember); // we really did move to B + + // the write landed on B, not A + await using var checkB = await serverB.ConnectAsync(); + Assert.Equal("committed-on-B", await checkB.GetDatabase().StringGetAsync(key)); + } + // An in-proc server that fails the first FailExecOps EXEC operations with a transient LOADING error, // discarding that attempt's queued commands so nothing is applied, then commits normally. - private sealed class ExecFailServer(ITestOutputHelper? log) : InProcessTestServer(log) + private sealed class ExecFailServer(ITestOutputHelper? log, EndPoint? endpoint = null) : InProcessTestServer(log, endpoint) { public int ExecOpsReceived { get; private set; } From cd2cd529e0cc6b8894f759571d5db3ba2ef25ca2 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 29 Jul 2026 09:19:41 +0100 Subject: [PATCH 24/33] use [AutoDatabase] for multigroupdatabase --- A.cs | 15 ++ .../AutoDatabaseGenerator.cs | 9 +- src/StackExchange.Redis/AutoDatabase.cs | 25 +++ .../MultiGroupDatabase.Arrays.Async.cs | 76 ---------- .../Availability/MultiGroupDatabase.Arrays.cs | 74 --------- .../Availability/MultiGroupDatabase.Async.cs | 66 -------- .../MultiGroupDatabase.Geo.Async.cs | 52 ------- .../Availability/MultiGroupDatabase.Geo.cs | 50 ------ .../MultiGroupDatabase.Hashes.Async.cs | 131 ---------------- .../Availability/MultiGroupDatabase.Hashes.cs | 133 ---------------- .../MultiGroupDatabase.HyperLogLog.Async.cs | 25 --- .../MultiGroupDatabase.HyperLogLog.cs | 23 --- .../MultiGroupDatabase.Keys.Async.cs | 80 ---------- .../Availability/MultiGroupDatabase.Keys.cs | 79 ---------- .../MultiGroupDatabase.Lists.Async.cs | 82 ---------- .../Availability/MultiGroupDatabase.Lists.cs | 80 ---------- .../Availability/MultiGroupDatabase.Scans.cs | 47 ++++++ .../MultiGroupDatabase.Sets.Async.cs | 67 -------- .../Availability/MultiGroupDatabase.Sets.cs | 68 --------- .../MultiGroupDatabase.SortedSets.Async.cs | 127 ---------------- .../MultiGroupDatabase.SortedSets.cs | 128 ---------------- .../MultiGroupDatabase.Streams.Async.cs | 143 ------------------ .../MultiGroupDatabase.Streams.cs | 142 ----------------- .../MultiGroupDatabase.Strings.Async.cs | 125 --------------- .../MultiGroupDatabase.Strings.cs | 124 --------------- .../MultiGroupDatabase.VectorSets.Async.cs | 55 ------- .../MultiGroupDatabase.VectorSets.cs | 53 ------- .../Availability/MultiGroupDatabase.cs | 107 +++++-------- .../Availability/RetryDatabase.cs | 25 +-- .../Availability/RetryTransaction.cs | 19 ++- 30 files changed, 161 insertions(+), 2069 deletions(-) create mode 100644 A.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.Arrays.Async.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.Arrays.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.Async.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.Geo.Async.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.Geo.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.Hashes.Async.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.Hashes.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.HyperLogLog.Async.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.HyperLogLog.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.Keys.Async.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.Keys.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.Lists.Async.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.Lists.cs create mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.Scans.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.Sets.Async.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.Sets.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.SortedSets.Async.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.SortedSets.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.Streams.Async.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.Streams.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.Strings.Async.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.Strings.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.VectorSets.Async.cs delete mode 100644 src/StackExchange.Redis/Availability/MultiGroupDatabase.VectorSets.cs diff --git a/A.cs b/A.cs new file mode 100644 index 0000000000..4bb6761589 --- /dev/null +++ b/A.cs @@ -0,0 +1,15 @@ +public interface IArgs { void Map(); } +public struct S : IArgs { public int Arg0; public long Arg1; public void Map() { } } +public interface IInner { int Do(int a, long b); } + +// candidate delegate: like Func but with 'in' on the state +public delegate TResult ArgsFunc(in TState state, TIn inner) where TState : struct, IArgs; + +public class C +{ + private TResult Execute(in TState state, ArgsFunc op) + where TState : struct, IArgs => op(in state, null!); + + // TEST 1: fully implicit lambda (what the generator emits today) + public int Implicit() => Execute(new S(), static (state, inner) => inner.Do(state.Arg0, state.Arg1)); +} diff --git a/eng/StackExchange.Redis.Build/AutoDatabaseGenerator.cs b/eng/StackExchange.Redis.Build/AutoDatabaseGenerator.cs index 544d1c479a..9f4c5f0226 100644 --- a/eng/StackExchange.Redis.Build/AutoDatabaseGenerator.cs +++ b/eng/StackExchange.Redis.Build/AutoDatabaseGenerator.cs @@ -194,11 +194,15 @@ internal enum KnownInterfaces // - IsConnected: a synchronous status probe that IDatabaseAsync carries; it would route through the // sync Execute funnel, which an async-only database (e.g. RetryDatabase) doesn't provide - and a // connection check shouldn't be retried in any case + // - IdentifyEndpoint / IdentifyEndpointAsync: a routing lookup ("which endpoint serves this key"), + // not a replayable server command; wrappers want their own behaviour here (e.g. a connection-group + // resolving against the currently-active member and returning null when there is none) // - streaming returns (IEnumerable / IAsyncEnumerable) whose execution is deferred private static bool SkipMethod(MethodInfo method) => !method.TypeArgs.IsEmpty || method.Name.Contains("Wait") || method.Name == "IsConnected" + || method.Name.Contains("IdentifyEndpoint") // transaction/batch factories are not server round-trips and cannot be captured-and-replayed; // the (few) databases that offer them implement them by hand || method.Name == "CreateTransaction" @@ -335,7 +339,10 @@ void AppendInterfaceMethods(KnownInterfaces knownType) else writer.Append(", "); writer.Append(p.Name); } - writer.Append("), static (state, inner) => inner.").Append(method.Name).Append("("); + // the funnels take the state by readonly-ref (see AutoDatabase{Sync|Async}Operation) to avoid + // copying the larger state structs; a lambda only binds to an `in` parameter if it says so, + // hence the explicit modifier (C# 14 allows it without also naming the type) + writer.Append("), static (in state, inner) => inner.").Append(method.Name).Append("("); for (int i = 0; i < method.Parameters.Length; i++) { if (i != 0) writer.Append(", "); diff --git a/src/StackExchange.Redis/AutoDatabase.cs b/src/StackExchange.Redis/AutoDatabase.cs index 395c5f70c5..f6d09edfed 100644 --- a/src/StackExchange.Redis/AutoDatabase.cs +++ b/src/StackExchange.Redis/AutoDatabase.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +using System.Threading.Tasks; namespace StackExchange.Redis; @@ -18,6 +19,30 @@ internal interface IRedisArgs object? UnMapper { get; } } +// The projections used by the auto-database funnels. These exist (rather than Func<,,>/Action<,>) purely to +// pass the captured state by readonly-reference: some of the generated state structs are chunky (a dozen-plus +// arguments), and by-value would copy the whole thing on every call. Note that a lambda can only bind to +// these if it declares the modifier - the generator emits `static (in state, inner) => ...` accordingly. +// The projections used by the auto-database funnels; there are exactly four real shapes, so each is named +// rather than being expressed generically over the target/return type. This lets the type system encode the +// invariants (sync goes to IDatabase, async goes to IDatabaseAsync and returns a Task) instead of leaving +// nonsense combinations like "sync database, Task result" expressible. +// +// TState is necessarily invariant: it is passed by readonly-ref, and variant type parameters cannot carry +// the struct constraint. TResult is covariant on the sync projection (it is the direct return); it cannot be +// on the async one, because there it appears inside the invariant Task. +internal delegate void AutoDatabaseSyncOperation(in TState state, IDatabase database) + where TState : struct, IRedisArgs; + +internal delegate TResult AutoDatabaseSyncOperation(in TState state, IDatabase database) + where TState : struct, IRedisArgs; + +internal delegate Task AutoDatabaseAsyncOperation(in TState state, IDatabaseAsync database) + where TState : struct, IRedisArgs; + +internal delegate Task AutoDatabaseAsyncOperation(in TState state, IDatabaseAsync database) + where TState : struct, IRedisArgs; + internal interface IRedisArgsMutator { RedisKey Map(RedisKey key); diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Arrays.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Arrays.Async.cs deleted file mode 100644 index 299159b430..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Arrays.Async.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.Threading.Tasks; - -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // Array Async operations - public Task ArraySetAsync(RedisKey key, RedisArrayIndex index, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArraySetAsync(key, index, value, flags); - - public Task ArraySetAsync(RedisKey key, RedisArrayIndex index, RedisValue[] values, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArraySetAsync(key, index, values, flags); - - public Task ArraySetAsync(RedisKey key, RedisArrayEntry[] values, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArraySetAsync(key, values, flags); - - public Task ArrayGetAsync(RedisKey key, RedisArrayIndex index, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayGetAsync(key, index, flags); - - public Task ArrayGetAsync(RedisKey key, RedisArrayIndex[] indices, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayGetAsync(key, indices, flags); - - public Task ArrayGetRangeAsync(RedisKey key, RedisArrayIndex start, RedisArrayIndex end, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayGetRangeAsync(key, start, end, flags); - - public Task ArrayLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayLengthAsync(key, flags); - - public Task ArrayCountAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayCountAsync(key, flags); - - public Task ArrayDeleteAsync(RedisKey key, RedisArrayIndex index, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayDeleteAsync(key, index, flags); - - public Task ArrayDeleteAsync(RedisKey key, RedisArrayIndex[] indices, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayDeleteAsync(key, indices, flags); - - public Task ArrayDeleteRangeAsync(RedisKey key, RedisArrayIndex start, RedisArrayIndex end, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayDeleteRangeAsync(key, start, end, flags); - - public Task ArrayDeleteRangeAsync(RedisKey key, RedisArrayRange[] ranges, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayDeleteRangeAsync(key, ranges, flags); - - public Task ArrayScanAsync(RedisKey key, RedisArrayIndex start, RedisArrayIndex end, int limit = 0, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayScanAsync(key, start, end, limit, flags); - - public Task ArrayGrepAsync(RedisKey key, ArrayGrepRequest request, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayGrepAsync(key, request, flags); - - public Task ArrayOperationAsync(RedisKey key, RedisArrayIndex start, RedisArrayIndex end, ArrayOperation operation, RedisValue operand = default, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayOperationAsync(key, start, end, operation, operand, flags); - - public Task ArrayRingAsync(RedisKey key, RedisArrayIndex maxLength, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayRingAsync(key, maxLength, value, flags); - - public Task ArrayRingAsync(RedisKey key, RedisArrayIndex maxLength, RedisValue[] values, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayRingAsync(key, maxLength, values, flags); - - public Task ArrayNextAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayNextAsync(key, flags); - - public Task ArrayInsertAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayInsertAsync(key, value, flags); - - public Task ArrayInsertAsync(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayInsertAsync(key, values, flags); - - public Task ArraySeekAsync(RedisKey key, RedisArrayIndex index, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArraySeekAsync(key, index, flags); - - public Task ArrayLastItemsAsync(RedisKey key, int count, bool reverse = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayLastItemsAsync(key, count, reverse, flags); - - public Task ArrayInfoAsync(RedisKey key, bool full = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayInfoAsync(key, full, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Arrays.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Arrays.cs deleted file mode 100644 index 45405e0520..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Arrays.cs +++ /dev/null @@ -1,74 +0,0 @@ -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // Array operations - public bool ArraySet(RedisKey key, RedisArrayIndex index, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArraySet(key, index, value, flags); - - public int ArraySet(RedisKey key, RedisArrayIndex index, RedisValue[] values, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArraySet(key, index, values, flags); - - public int ArraySet(RedisKey key, RedisArrayEntry[] values, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArraySet(key, values, flags); - - public RedisValue ArrayGet(RedisKey key, RedisArrayIndex index, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayGet(key, index, flags); - - public RedisValue[] ArrayGet(RedisKey key, RedisArrayIndex[] indices, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayGet(key, indices, flags); - - public RedisValue[] ArrayGetRange(RedisKey key, RedisArrayIndex start, RedisArrayIndex end, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayGetRange(key, start, end, flags); - - public RedisArrayIndex ArrayLength(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayLength(key, flags); - - public RedisArrayIndex ArrayCount(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayCount(key, flags); - - public bool ArrayDelete(RedisKey key, RedisArrayIndex index, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayDelete(key, index, flags); - - public int ArrayDelete(RedisKey key, RedisArrayIndex[] indices, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayDelete(key, indices, flags); - - public RedisArrayIndex ArrayDeleteRange(RedisKey key, RedisArrayIndex start, RedisArrayIndex end, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayDeleteRange(key, start, end, flags); - - public RedisArrayIndex ArrayDeleteRange(RedisKey key, RedisArrayRange[] ranges, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayDeleteRange(key, ranges, flags); - - public RedisArrayEntry[] ArrayScan(RedisKey key, RedisArrayIndex start, RedisArrayIndex end, int limit = 0, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayScan(key, start, end, limit, flags); - - public RedisArrayEntry[] ArrayGrep(RedisKey key, ArrayGrepRequest request, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayGrep(key, request, flags); - - public RedisValue ArrayOperation(RedisKey key, RedisArrayIndex start, RedisArrayIndex end, ArrayOperation operation, RedisValue operand = default, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayOperation(key, start, end, operation, operand, flags); - - public RedisArrayIndex ArrayRing(RedisKey key, RedisArrayIndex maxLength, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayRing(key, maxLength, value, flags); - - public RedisArrayIndex ArrayRing(RedisKey key, RedisArrayIndex maxLength, RedisValue[] values, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayRing(key, maxLength, values, flags); - - public RedisArrayIndex? ArrayNext(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayNext(key, flags); - - public RedisArrayIndex ArrayInsert(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayInsert(key, value, flags); - - public RedisArrayIndex ArrayInsert(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayInsert(key, values, flags); - - public bool ArraySeek(RedisKey key, RedisArrayIndex index, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArraySeek(key, index, flags); - - public RedisValue[] ArrayLastItems(RedisKey key, int count, bool reverse = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayLastItems(key, count, reverse, flags); - - public ArrayInfo ArrayInfo(RedisKey key, bool full = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ArrayInfo(key, full, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Async.cs deleted file mode 100644 index 1e8dbc05cb..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Async.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.Net; -using System.Threading.Tasks; - -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // Async methods - Core operations - public Task DebugObjectAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().DebugObjectAsync(key, flags); - - public Task IdentifyEndpointAsync(RedisKey key = default, CommandFlags flags = CommandFlags.None) - => TryGetActiveDatabase()?.IdentifyEndpointAsync(key, flags) ?? MultiGroupMultiplexer.NoEndpoint; - - public Task KeyMigrateAsync(RedisKey key, EndPoint toServer, int toDatabase = 0, int timeoutMilliseconds = 0, MigrateOptions migrateOptions = MigrateOptions.None, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyMigrateAsync(key, toServer, toDatabase, timeoutMilliseconds, migrateOptions, flags); - - public Task PingAsync(CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().PingAsync(flags); - - public Task PublishAsync(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().PublishAsync(channel, message, flags); - - public Task ExecuteAsync(string command, params object[] args) - => GetActiveDatabase().ExecuteAsync(command, args); - - public Task ExecuteAsync(string command, System.Collections.Generic.ICollection? args, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ExecuteAsync(command, args, flags); - - public Task ScriptEvaluateAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ScriptEvaluateAsync(script, keys, values, flags); - - public Task ScriptEvaluateAsync(byte[] hash, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ScriptEvaluateAsync(hash, keys, values, flags); - - public Task ScriptEvaluateAsync(LuaScript script, object? parameters = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ScriptEvaluateAsync(script, parameters, flags); - - public Task ScriptEvaluateAsync(LoadedLuaScript script, object? parameters = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ScriptEvaluateAsync(script, parameters, flags); - - public Task ScriptEvaluateReadOnlyAsync(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ScriptEvaluateReadOnlyAsync(script, keys, values, flags); - - public Task ScriptEvaluateReadOnlyAsync(byte[] hash, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ScriptEvaluateReadOnlyAsync(hash, keys, values, flags); - - public Task LockExtendAsync(RedisKey key, RedisValue value, TimeSpan expiry, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().LockExtendAsync(key, value, expiry, flags); - - public Task LockQueryAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().LockQueryAsync(key, flags); - - public Task LockReleaseAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().LockReleaseAsync(key, value, flags); - - public Task LockTakeAsync(RedisKey key, RedisValue value, TimeSpan expiry, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().LockTakeAsync(key, value, expiry, flags); - - public Task SortAsync(RedisKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, RedisValue by = default, RedisValue[]? get = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortAsync(key, skip, take, order, sortType, by, get, flags); - - public Task SortAndStoreAsync(RedisKey destination, RedisKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, RedisValue by = default, RedisValue[]? get = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortAndStoreAsync(destination, key, skip, take, order, sortType, by, get, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Geo.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Geo.Async.cs deleted file mode 100644 index 32965a40e6..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Geo.Async.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Threading.Tasks; - -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // Geo Async - public Task GeoAddAsync(RedisKey key, double longitude, double latitude, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoAddAsync(key, longitude, latitude, member, flags); - - public Task GeoAddAsync(RedisKey key, GeoEntry value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoAddAsync(key, value, flags); - - public Task GeoAddAsync(RedisKey key, GeoEntry[] values, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoAddAsync(key, values, flags); - - public Task GeoRemoveAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoRemoveAsync(key, member, flags); - - public Task GeoDistanceAsync(RedisKey key, RedisValue member1, RedisValue member2, GeoUnit unit = GeoUnit.Meters, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoDistanceAsync(key, member1, member2, unit, flags); - - public Task GeoHashAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoHashAsync(key, members, flags); - - public Task GeoHashAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoHashAsync(key, member, flags); - - public Task GeoPositionAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoPositionAsync(key, members, flags); - - public Task GeoPositionAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoPositionAsync(key, member, flags); - - public Task GeoRadiusAsync(RedisKey key, RedisValue member, double radius, GeoUnit unit = GeoUnit.Meters, int count = -1, Order? order = null, GeoRadiusOptions options = GeoRadiusOptions.Default, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoRadiusAsync(key, member, radius, unit, count, order, options, flags); - - public Task GeoRadiusAsync(RedisKey key, double longitude, double latitude, double radius, GeoUnit unit = GeoUnit.Meters, int count = -1, Order? order = null, GeoRadiusOptions options = GeoRadiusOptions.Default, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoRadiusAsync(key, longitude, latitude, radius, unit, count, order, options, flags); - - public Task GeoSearchAsync(RedisKey key, RedisValue member, GeoSearchShape shape, int count = -1, bool demandClosest = true, Order? order = null, GeoRadiusOptions options = GeoRadiusOptions.Default, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoSearchAsync(key, member, shape, count, demandClosest, order, options, flags); - - public Task GeoSearchAsync(RedisKey key, double longitude, double latitude, GeoSearchShape shape, int count = -1, bool demandClosest = true, Order? order = null, GeoRadiusOptions options = GeoRadiusOptions.Default, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoSearchAsync(key, longitude, latitude, shape, count, demandClosest, order, options, flags); - - public Task GeoSearchAndStoreAsync(RedisKey sourceKey, RedisKey destinationKey, RedisValue member, GeoSearchShape shape, int count = -1, bool demandClosest = true, Order? order = null, bool storeDistances = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoSearchAndStoreAsync(sourceKey, destinationKey, member, shape, count, demandClosest, order, storeDistances, flags); - - public Task GeoSearchAndStoreAsync(RedisKey sourceKey, RedisKey destinationKey, double longitude, double latitude, GeoSearchShape shape, int count = -1, bool demandClosest = true, Order? order = null, bool storeDistances = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoSearchAndStoreAsync(sourceKey, destinationKey, longitude, latitude, shape, count, demandClosest, order, storeDistances, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Geo.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Geo.cs deleted file mode 100644 index 662c4de3f1..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Geo.cs +++ /dev/null @@ -1,50 +0,0 @@ -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // Geo operations - public bool GeoAdd(RedisKey key, double longitude, double latitude, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoAdd(key, longitude, latitude, member, flags); - - public bool GeoAdd(RedisKey key, GeoEntry value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoAdd(key, value, flags); - - public long GeoAdd(RedisKey key, GeoEntry[] values, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoAdd(key, values, flags); - - public bool GeoRemove(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoRemove(key, member, flags); - - public double? GeoDistance(RedisKey key, RedisValue member1, RedisValue member2, GeoUnit unit = GeoUnit.Meters, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoDistance(key, member1, member2, unit, flags); - - public string?[] GeoHash(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoHash(key, members, flags); - - public string? GeoHash(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoHash(key, member, flags); - - public GeoPosition?[] GeoPosition(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoPosition(key, members, flags); - - public GeoPosition? GeoPosition(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoPosition(key, member, flags); - - public GeoRadiusResult[] GeoRadius(RedisKey key, RedisValue member, double radius, GeoUnit unit = GeoUnit.Meters, int count = -1, Order? order = null, GeoRadiusOptions options = GeoRadiusOptions.Default, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoRadius(key, member, radius, unit, count, order, options, flags); - - public GeoRadiusResult[] GeoRadius(RedisKey key, double longitude, double latitude, double radius, GeoUnit unit = GeoUnit.Meters, int count = -1, Order? order = null, GeoRadiusOptions options = GeoRadiusOptions.Default, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoRadius(key, longitude, latitude, radius, unit, count, order, options, flags); - - public GeoRadiusResult[] GeoSearch(RedisKey key, RedisValue member, GeoSearchShape shape, int count = -1, bool demandClosest = true, Order? order = null, GeoRadiusOptions options = GeoRadiusOptions.Default, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoSearch(key, member, shape, count, demandClosest, order, options, flags); - - public GeoRadiusResult[] GeoSearch(RedisKey key, double longitude, double latitude, GeoSearchShape shape, int count = -1, bool demandClosest = true, Order? order = null, GeoRadiusOptions options = GeoRadiusOptions.Default, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoSearch(key, longitude, latitude, shape, count, demandClosest, order, options, flags); - - public long GeoSearchAndStore(RedisKey sourceKey, RedisKey destinationKey, RedisValue member, GeoSearchShape shape, int count = -1, bool demandClosest = true, Order? order = null, bool storeDistances = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoSearchAndStore(sourceKey, destinationKey, member, shape, count, demandClosest, order, storeDistances, flags); - - public long GeoSearchAndStore(RedisKey sourceKey, RedisKey destinationKey, double longitude, double latitude, GeoSearchShape shape, int count = -1, bool demandClosest = true, Order? order = null, bool storeDistances = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().GeoSearchAndStore(sourceKey, destinationKey, longitude, latitude, shape, count, demandClosest, order, storeDistances, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Hashes.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Hashes.Async.cs deleted file mode 100644 index b65aba5d54..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Hashes.Async.cs +++ /dev/null @@ -1,131 +0,0 @@ -using System; -using System.Threading.Tasks; - -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // Hash Async - public Task HashImportAsync(RedisKey key, HashImport fieldSet, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashImportAsync(key, fieldSet, values, flags); - - public Task HashDecrementAsync(RedisKey key, RedisValue hashField, long value = 1, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashDecrementAsync(key, hashField, value, flags); - - public Task HashDecrementAsync(RedisKey key, RedisValue hashField, double value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashDecrementAsync(key, hashField, value, flags); - - public Task HashDeleteAsync(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashDeleteAsync(key, hashField, flags); - - public Task HashDeleteAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashDeleteAsync(key, hashFields, flags); - - public Task HashExistsAsync(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashExistsAsync(key, hashField, flags); - - public Task HashFieldExpireAsync(RedisKey key, RedisValue[] hashFields, TimeSpan expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldExpireAsync(key, hashFields, expiry, when, flags); - - public Task HashFieldExpireAsync(RedisKey key, RedisValue[] hashFields, DateTime expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldExpireAsync(key, hashFields, expiry, when, flags); - - public Task HashFieldGetExpireDateTimeAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldGetExpireDateTimeAsync(key, hashFields, flags); - - public Task HashFieldPersistAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldPersistAsync(key, hashFields, flags); - - public Task HashFieldGetTimeToLiveAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldGetTimeToLiveAsync(key, hashFields, flags); - - public Task HashGetAsync(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashGetAsync(key, hashField, flags); - - public Task?> HashGetLeaseAsync(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashGetLeaseAsync(key, hashField, flags); - - public Task HashGetAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashGetAsync(key, hashFields, flags); - - public Task HashFieldGetAndDeleteAsync(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldGetAndDeleteAsync(key, hashField, flags); - - public Task?> HashFieldGetLeaseAndDeleteAsync(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldGetLeaseAndDeleteAsync(key, hashField, flags); - - public Task HashFieldGetAndDeleteAsync(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldGetAndDeleteAsync(key, hashFields, flags); - - public Task HashFieldGetAndSetExpiryAsync(RedisKey key, RedisValue hashField, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldGetAndSetExpiryAsync(key, hashField, expiry, persist, flags); - - public Task HashFieldGetAndSetExpiryAsync(RedisKey key, RedisValue hashField, DateTime expiry, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldGetAndSetExpiryAsync(key, hashField, expiry, flags); - - public Task?> HashFieldGetLeaseAndSetExpiryAsync(RedisKey key, RedisValue hashField, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldGetLeaseAndSetExpiryAsync(key, hashField, expiry, persist, flags); - - public Task?> HashFieldGetLeaseAndSetExpiryAsync(RedisKey key, RedisValue hashField, DateTime expiry, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldGetLeaseAndSetExpiryAsync(key, hashField, expiry, flags); - - public Task HashFieldGetAndSetExpiryAsync(RedisKey key, RedisValue[] hashFields, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldGetAndSetExpiryAsync(key, hashFields, expiry, persist, flags); - - public Task HashFieldGetAndSetExpiryAsync(RedisKey key, RedisValue[] hashFields, DateTime expiry, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldGetAndSetExpiryAsync(key, hashFields, expiry, flags); - - public Task HashFieldSetAndSetExpiryAsync(RedisKey key, RedisValue field, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldSetAndSetExpiryAsync(key, field, value, expiry, keepTtl, when, flags); - - public Task HashFieldSetAndSetExpiryAsync(RedisKey key, RedisValue field, RedisValue value, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldSetAndSetExpiryAsync(key, field, value, expiry, when, flags); - - public Task HashFieldSetAndSetExpiryAsync(RedisKey key, HashEntry[] hashFields, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldSetAndSetExpiryAsync(key, hashFields, expiry, keepTtl, when, flags); - - public Task HashFieldSetAndSetExpiryAsync(RedisKey key, HashEntry[] hashFields, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldSetAndSetExpiryAsync(key, hashFields, expiry, when, flags); - - public Task HashGetAllAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashGetAllAsync(key, flags); - - public Task HashIncrementAsync(RedisKey key, RedisValue hashField, long value = 1, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashIncrementAsync(key, hashField, value, flags); - - public Task HashIncrementAsync(RedisKey key, RedisValue hashField, double value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashIncrementAsync(key, hashField, value, flags); - - public Task HashKeysAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashKeysAsync(key, flags); - - public Task HashLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashLengthAsync(key, flags); - - public Task HashRandomFieldAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashRandomFieldAsync(key, flags); - - public Task HashRandomFieldsAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashRandomFieldsAsync(key, count, flags); - - public Task HashRandomFieldsWithValuesAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashRandomFieldsWithValuesAsync(key, count, flags); - - public System.Collections.Generic.IAsyncEnumerable HashScanAsync(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashScanAsync(key, pattern, pageSize, cursor, pageOffset, flags); - - public System.Collections.Generic.IAsyncEnumerable HashScanNoValuesAsync(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashScanNoValuesAsync(key, pattern, pageSize, cursor, pageOffset, flags); - - public Task HashSetAsync(RedisKey key, RedisValue hashField, RedisValue value, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashSetAsync(key, hashField, value, when, flags); - - public Task HashSetAsync(RedisKey key, HashEntry[] hashFields, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashSetAsync(key, hashFields, flags); - - public Task HashStringLengthAsync(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashStringLengthAsync(key, hashField, flags); - - public Task HashValuesAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashValuesAsync(key, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Hashes.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Hashes.cs deleted file mode 100644 index 0cb5d13775..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Hashes.cs +++ /dev/null @@ -1,133 +0,0 @@ -using System; - -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // Hash operations - public void HashImport(RedisKey key, HashImport fieldSet, ReadOnlyMemory values, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashImport(key, fieldSet, values, flags); - - public long HashDecrement(RedisKey key, RedisValue hashField, long value = 1, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashDecrement(key, hashField, value, flags); - - public double HashDecrement(RedisKey key, RedisValue hashField, double value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashDecrement(key, hashField, value, flags); - - public bool HashDelete(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashDelete(key, hashField, flags); - - public long HashDelete(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashDelete(key, hashFields, flags); - - public bool HashExists(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashExists(key, hashField, flags); - - public ExpireResult[] HashFieldExpire(RedisKey key, RedisValue[] hashFields, TimeSpan expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldExpire(key, hashFields, expiry, when, flags); - - public ExpireResult[] HashFieldExpire(RedisKey key, RedisValue[] hashFields, DateTime expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldExpire(key, hashFields, expiry, when, flags); - - public long[] HashFieldGetExpireDateTime(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldGetExpireDateTime(key, hashFields, flags); - - public PersistResult[] HashFieldPersist(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldPersist(key, hashFields, flags); - - public long[] HashFieldGetTimeToLive(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldGetTimeToLive(key, hashFields, flags); - - public RedisValue HashGet(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashGet(key, hashField, flags); - - public Lease? HashGetLease(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashGetLease(key, hashField, flags); - - public RedisValue[] HashGet(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashGet(key, hashFields, flags); - - public RedisValue HashFieldGetAndDelete(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldGetAndDelete(key, hashField, flags); - - public Lease? HashFieldGetLeaseAndDelete(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldGetLeaseAndDelete(key, hashField, flags); - - public RedisValue[] HashFieldGetAndDelete(RedisKey key, RedisValue[] hashFields, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldGetAndDelete(key, hashFields, flags); - - public RedisValue HashFieldGetAndSetExpiry(RedisKey key, RedisValue hashField, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldGetAndSetExpiry(key, hashField, expiry, persist, flags); - - public RedisValue HashFieldGetAndSetExpiry(RedisKey key, RedisValue hashField, DateTime expiry, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldGetAndSetExpiry(key, hashField, expiry, flags); - - public Lease? HashFieldGetLeaseAndSetExpiry(RedisKey key, RedisValue hashField, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldGetLeaseAndSetExpiry(key, hashField, expiry, persist, flags); - - public Lease? HashFieldGetLeaseAndSetExpiry(RedisKey key, RedisValue hashField, DateTime expiry, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldGetLeaseAndSetExpiry(key, hashField, expiry, flags); - - public RedisValue[] HashFieldGetAndSetExpiry(RedisKey key, RedisValue[] hashFields, TimeSpan? expiry = null, bool persist = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldGetAndSetExpiry(key, hashFields, expiry, persist, flags); - - public RedisValue[] HashFieldGetAndSetExpiry(RedisKey key, RedisValue[] hashFields, DateTime expiry, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldGetAndSetExpiry(key, hashFields, expiry, flags); - - public RedisValue HashFieldSetAndSetExpiry(RedisKey key, RedisValue field, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldSetAndSetExpiry(key, field, value, expiry, keepTtl, when, flags); - - public RedisValue HashFieldSetAndSetExpiry(RedisKey key, RedisValue field, RedisValue value, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldSetAndSetExpiry(key, field, value, expiry, when, flags); - - public RedisValue HashFieldSetAndSetExpiry(RedisKey key, HashEntry[] hashFields, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldSetAndSetExpiry(key, hashFields, expiry, keepTtl, when, flags); - - public RedisValue HashFieldSetAndSetExpiry(RedisKey key, HashEntry[] hashFields, DateTime expiry, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashFieldSetAndSetExpiry(key, hashFields, expiry, when, flags); - - public HashEntry[] HashGetAll(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashGetAll(key, flags); - - public long HashIncrement(RedisKey key, RedisValue hashField, long value = 1, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashIncrement(key, hashField, value, flags); - - public double HashIncrement(RedisKey key, RedisValue hashField, double value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashIncrement(key, hashField, value, flags); - - public RedisValue[] HashKeys(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashKeys(key, flags); - - public long HashLength(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashLength(key, flags); - - public RedisValue HashRandomField(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashRandomField(key, flags); - - public RedisValue[] HashRandomFields(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashRandomFields(key, count, flags); - - public HashEntry[] HashRandomFieldsWithValues(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashRandomFieldsWithValues(key, count, flags); - - public System.Collections.Generic.IEnumerable HashScan(RedisKey key, RedisValue pattern, int pageSize, CommandFlags flags) - => GetActiveDatabase().HashScan(key, pattern, pageSize, flags); - - public System.Collections.Generic.IEnumerable HashScan(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashScan(key, pattern, pageSize, cursor, pageOffset, flags); - - public System.Collections.Generic.IEnumerable HashScanNoValues(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashScanNoValues(key, pattern, pageSize, cursor, pageOffset, flags); - - public bool HashSet(RedisKey key, RedisValue hashField, RedisValue value, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashSet(key, hashField, value, when, flags); - - public void HashSet(RedisKey key, HashEntry[] hashFields, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashSet(key, hashFields, flags); - - public long HashStringLength(RedisKey key, RedisValue hashField, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashStringLength(key, hashField, flags); - - public RedisValue[] HashValues(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HashValues(key, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.HyperLogLog.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.HyperLogLog.Async.cs deleted file mode 100644 index 933d0131e2..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.HyperLogLog.Async.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Threading.Tasks; - -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // HyperLogLog Async - public Task HyperLogLogAddAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HyperLogLogAddAsync(key, value, flags); - - public Task HyperLogLogAddAsync(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HyperLogLogAddAsync(key, values, flags); - - public Task HyperLogLogLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HyperLogLogLengthAsync(key, flags); - - public Task HyperLogLogLengthAsync(RedisKey[] keys, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HyperLogLogLengthAsync(keys, flags); - - public Task HyperLogLogMergeAsync(RedisKey destination, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HyperLogLogMergeAsync(destination, first, second, flags); - - public Task HyperLogLogMergeAsync(RedisKey destination, RedisKey[] sourceKeys, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HyperLogLogMergeAsync(destination, sourceKeys, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.HyperLogLog.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.HyperLogLog.cs deleted file mode 100644 index b222deea19..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.HyperLogLog.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // HyperLogLog operations - public bool HyperLogLogAdd(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HyperLogLogAdd(key, value, flags); - - public bool HyperLogLogAdd(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HyperLogLogAdd(key, values, flags); - - public long HyperLogLogLength(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HyperLogLogLength(key, flags); - - public long HyperLogLogLength(RedisKey[] keys, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HyperLogLogLength(keys, flags); - - public void HyperLogLogMerge(RedisKey destination, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HyperLogLogMerge(destination, first, second, flags); - - public void HyperLogLogMerge(RedisKey destination, RedisKey[] sourceKeys, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().HyperLogLogMerge(destination, sourceKeys, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Keys.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Keys.Async.cs deleted file mode 100644 index a944d57f49..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Keys.Async.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using System.Threading.Tasks; - -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // Key Async - public Task KeyCopyAsync(RedisKey sourceKey, RedisKey destinationKey, int destinationDatabase = -1, bool replace = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyCopyAsync(sourceKey, destinationKey, destinationDatabase, replace, flags); - - public Task KeyDeleteAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyDeleteAsync(key, flags); - - public Task KeyDeleteAsync(RedisKey[] keys, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyDeleteAsync(keys, flags); - - public Task KeyDumpAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyDumpAsync(key, flags); - - public Task KeyEncodingAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyEncodingAsync(key, flags); - - public Task KeyExistsAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyExistsAsync(key, flags); - - public Task KeyExistsAsync(RedisKey[] keys, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyExistsAsync(keys, flags); - - public Task KeyExpireAsync(RedisKey key, TimeSpan? expiry, CommandFlags flags) - => GetActiveDatabase().KeyExpireAsync(key, expiry, flags); - - public Task KeyExpireAsync(RedisKey key, TimeSpan? expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyExpireAsync(key, expiry, when, flags); - - public Task KeyExpireAsync(RedisKey key, DateTime? expiry, CommandFlags flags) - => GetActiveDatabase().KeyExpireAsync(key, expiry, flags); - - public Task KeyExpireAsync(RedisKey key, DateTime? expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyExpireAsync(key, expiry, when, flags); - - public Task KeyExpireTimeAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyExpireTimeAsync(key, flags); - - public Task KeyFrequencyAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyFrequencyAsync(key, flags); - - public Task KeyIdleTimeAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyIdleTimeAsync(key, flags); - - public Task KeyMoveAsync(RedisKey key, int database, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyMoveAsync(key, database, flags); - - public Task KeyPersistAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyPersistAsync(key, flags); - - public Task KeyRandomAsync(CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyRandomAsync(flags); - - public Task KeyRefCountAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyRefCountAsync(key, flags); - - public Task KeyRenameAsync(RedisKey key, RedisKey newKey, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyRenameAsync(key, newKey, when, flags); - - public Task KeyRestoreAsync(RedisKey key, byte[] value, TimeSpan? expiry = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyRestoreAsync(key, value, expiry, flags); - - public Task KeyTimeToLiveAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyTimeToLiveAsync(key, flags); - - public Task KeyTouchAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyTouchAsync(key, flags); - - public Task KeyTouchAsync(RedisKey[] keys, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyTouchAsync(keys, flags); - - public Task KeyTypeAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyTypeAsync(key, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Keys.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Keys.cs deleted file mode 100644 index 119536084e..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Keys.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System; - -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // Key operations - public bool KeyCopy(RedisKey sourceKey, RedisKey destinationKey, int destinationDatabase = -1, bool replace = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyCopy(sourceKey, destinationKey, destinationDatabase, replace, flags); - - public bool KeyDelete(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyDelete(key, flags); - - public long KeyDelete(RedisKey[] keys, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyDelete(keys, flags); - - public byte[]? KeyDump(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyDump(key, flags); - - public string? KeyEncoding(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyEncoding(key, flags); - - public bool KeyExists(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyExists(key, flags); - - public long KeyExists(RedisKey[] keys, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyExists(keys, flags); - - public bool KeyExpire(RedisKey key, TimeSpan? expiry, CommandFlags flags) - => GetActiveDatabase().KeyExpire(key, expiry, flags); - - public bool KeyExpire(RedisKey key, TimeSpan? expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyExpire(key, expiry, when, flags); - - public bool KeyExpire(RedisKey key, DateTime? expiry, CommandFlags flags) - => GetActiveDatabase().KeyExpire(key, expiry, flags); - - public bool KeyExpire(RedisKey key, DateTime? expiry, ExpireWhen when = ExpireWhen.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyExpire(key, expiry, when, flags); - - public DateTime? KeyExpireTime(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyExpireTime(key, flags); - - public long? KeyFrequency(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyFrequency(key, flags); - - public TimeSpan? KeyIdleTime(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyIdleTime(key, flags); - - public bool KeyMove(RedisKey key, int database, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyMove(key, database, flags); - - public bool KeyPersist(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyPersist(key, flags); - - public RedisKey KeyRandom(CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyRandom(flags); - - public long? KeyRefCount(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyRefCount(key, flags); - - public bool KeyRename(RedisKey key, RedisKey newKey, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyRename(key, newKey, when, flags); - - public void KeyRestore(RedisKey key, byte[] value, TimeSpan? expiry = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyRestore(key, value, expiry, flags); - - public TimeSpan? KeyTimeToLive(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyTimeToLive(key, flags); - - public bool KeyTouch(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyTouch(key, flags); - - public long KeyTouch(RedisKey[] keys, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyTouch(keys, flags); - - public RedisType KeyType(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyType(key, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Lists.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Lists.Async.cs deleted file mode 100644 index d1d8474187..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Lists.Async.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.Threading.Tasks; - -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // List Async operations - public Task ListGetByIndexAsync(RedisKey key, long index, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListGetByIndexAsync(key, index, flags); - - public Task ListInsertAfterAsync(RedisKey key, RedisValue pivot, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListInsertAfterAsync(key, pivot, value, flags); - - public Task ListInsertBeforeAsync(RedisKey key, RedisValue pivot, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListInsertBeforeAsync(key, pivot, value, flags); - - public Task ListLeftPopAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListLeftPopAsync(key, flags); - - public Task ListLeftPopAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListLeftPopAsync(key, count, flags); - - public Task ListLeftPopAsync(RedisKey[] keys, long count, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListLeftPopAsync(keys, count, flags); - - public Task ListLeftPushAsync(RedisKey key, RedisValue value, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListLeftPushAsync(key, value, when, flags); - - public Task ListLeftPushAsync(RedisKey key, RedisValue[] values, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListLeftPushAsync(key, values, when, flags); - - public Task ListLeftPushAsync(RedisKey key, RedisValue[] values, CommandFlags flags) - => GetActiveDatabase().ListLeftPushAsync(key, values, flags); - - public Task ListLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListLengthAsync(key, flags); - - public Task ListMoveAsync(RedisKey sourceKey, RedisKey destinationKey, ListSide sourceSide, ListSide destinationSide, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListMoveAsync(sourceKey, destinationKey, sourceSide, destinationSide, flags); - - public Task ListMoveAsync(RedisKey sourceKey, RedisKey destinationKey, ListSide sourceSide, ListSide destinationSide, long count, ListMoveCount mode = ListMoveCount.UpTo, ListMoveOrder order = ListMoveOrder.Bulk, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListMoveAsync(sourceKey, destinationKey, sourceSide, destinationSide, count, mode, order, flags); - - public Task ListPositionAsync(RedisKey key, RedisValue element, long rank = 1, long maxLength = 0, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListPositionAsync(key, element, rank, maxLength, flags); - - public Task ListPositionsAsync(RedisKey key, RedisValue element, long count, long rank = 1, long maxLength = 0, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListPositionsAsync(key, element, count, rank, maxLength, flags); - - public Task ListRangeAsync(RedisKey key, long start = 0, long stop = -1, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListRangeAsync(key, start, stop, flags); - - public Task ListRemoveAsync(RedisKey key, RedisValue value, long count = 0, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListRemoveAsync(key, value, count, flags); - - public Task ListRightPopAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListRightPopAsync(key, flags); - - public Task ListRightPopAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListRightPopAsync(key, count, flags); - - public Task ListRightPopAsync(RedisKey[] keys, long count, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListRightPopAsync(keys, count, flags); - - public Task ListRightPopLeftPushAsync(RedisKey source, RedisKey destination, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListRightPopLeftPushAsync(source, destination, flags); - - public Task ListRightPushAsync(RedisKey key, RedisValue value, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListRightPushAsync(key, value, when, flags); - - public Task ListRightPushAsync(RedisKey key, RedisValue[] values, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListRightPushAsync(key, values, when, flags); - - public Task ListRightPushAsync(RedisKey key, RedisValue[] values, CommandFlags flags) - => GetActiveDatabase().ListRightPushAsync(key, values, flags); - - public Task ListSetByIndexAsync(RedisKey key, long index, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListSetByIndexAsync(key, index, value, flags); - - public Task ListTrimAsync(RedisKey key, long start, long stop, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListTrimAsync(key, start, stop, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Lists.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Lists.cs deleted file mode 100644 index 238fba8ab3..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Lists.cs +++ /dev/null @@ -1,80 +0,0 @@ -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // List operations - public RedisValue ListGetByIndex(RedisKey key, long index, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListGetByIndex(key, index, flags); - - public long ListInsertAfter(RedisKey key, RedisValue pivot, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListInsertAfter(key, pivot, value, flags); - - public long ListInsertBefore(RedisKey key, RedisValue pivot, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListInsertBefore(key, pivot, value, flags); - - public RedisValue ListLeftPop(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListLeftPop(key, flags); - - public RedisValue[] ListLeftPop(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListLeftPop(key, count, flags); - - public ListPopResult ListLeftPop(RedisKey[] keys, long count, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListLeftPop(keys, count, flags); - - public long ListLeftPush(RedisKey key, RedisValue value, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListLeftPush(key, value, when, flags); - - public long ListLeftPush(RedisKey key, RedisValue[] values, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListLeftPush(key, values, when, flags); - - public long ListLeftPush(RedisKey key, RedisValue[] values, CommandFlags flags) - => GetActiveDatabase().ListLeftPush(key, values, flags); - - public long ListLength(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListLength(key, flags); - - public RedisValue ListMove(RedisKey sourceKey, RedisKey destinationKey, ListSide sourceSide, ListSide destinationSide, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListMove(sourceKey, destinationKey, sourceSide, destinationSide, flags); - - public RedisValue[]? ListMove(RedisKey sourceKey, RedisKey destinationKey, ListSide sourceSide, ListSide destinationSide, long count, ListMoveCount mode = ListMoveCount.UpTo, ListMoveOrder order = ListMoveOrder.Bulk, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListMove(sourceKey, destinationKey, sourceSide, destinationSide, count, mode, order, flags); - - public long ListPosition(RedisKey key, RedisValue element, long rank = 1, long maxLength = 0, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListPosition(key, element, rank, maxLength, flags); - - public long[] ListPositions(RedisKey key, RedisValue element, long count, long rank = 1, long maxLength = 0, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListPositions(key, element, count, rank, maxLength, flags); - - public RedisValue[] ListRange(RedisKey key, long start = 0, long stop = -1, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListRange(key, start, stop, flags); - - public long ListRemove(RedisKey key, RedisValue value, long count = 0, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListRemove(key, value, count, flags); - - public RedisValue ListRightPop(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListRightPop(key, flags); - - public RedisValue[] ListRightPop(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListRightPop(key, count, flags); - - public ListPopResult ListRightPop(RedisKey[] keys, long count, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListRightPop(keys, count, flags); - - public RedisValue ListRightPopLeftPush(RedisKey source, RedisKey destination, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListRightPopLeftPush(source, destination, flags); - - public long ListRightPush(RedisKey key, RedisValue value, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListRightPush(key, value, when, flags); - - public long ListRightPush(RedisKey key, RedisValue[] values, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListRightPush(key, values, when, flags); - - public long ListRightPush(RedisKey key, RedisValue[] values, CommandFlags flags) - => GetActiveDatabase().ListRightPush(key, values, flags); - - public void ListSetByIndex(RedisKey key, long index, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListSetByIndex(key, index, value, flags); - - public void ListTrim(RedisKey key, long start, long stop, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ListTrim(key, start, stop, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Scans.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Scans.cs new file mode 100644 index 0000000000..e9fbe96df5 --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Scans.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; + +namespace StackExchange.Redis.Availability; + +// Streaming cursor scans (IEnumerable / IAsyncEnumerable) are deferred-execution and don't fit the +// capture-and-replay shape, so [AutoDatabase] skips them; they forward straight to the active member. +internal sealed partial class MultiGroupDatabase +{ + public IEnumerable HashScan(RedisKey key, RedisValue pattern, int pageSize, CommandFlags flags) + => GetActiveDatabase().HashScan(key, pattern, pageSize, flags); + + public IEnumerable HashScan(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashScan(key, pattern, pageSize, cursor, pageOffset, flags); + + public IEnumerable HashScanNoValues(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashScanNoValues(key, pattern, pageSize, cursor, pageOffset, flags); + + public IEnumerable SetScan(RedisKey key, RedisValue pattern, int pageSize, CommandFlags flags) + => GetActiveDatabase().SetScan(key, pattern, pageSize, flags); + + public IEnumerable SetScan(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetScan(key, pattern, pageSize, cursor, pageOffset, flags); + + public IEnumerable SortedSetScan(RedisKey key, RedisValue pattern, int pageSize, CommandFlags flags) + => GetActiveDatabase().SortedSetScan(key, pattern, pageSize, flags); + + public IEnumerable SortedSetScan(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetScan(key, pattern, pageSize, cursor, pageOffset, flags); + + public IEnumerable VectorSetRangeEnumerate(RedisKey key, RedisValue start = default, RedisValue end = default, long count = 100, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetRangeEnumerate(key, start, end, count, exclude, flags); + + public IAsyncEnumerable HashScanAsync(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashScanAsync(key, pattern, pageSize, cursor, pageOffset, flags); + + public IAsyncEnumerable HashScanNoValuesAsync(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().HashScanNoValuesAsync(key, pattern, pageSize, cursor, pageOffset, flags); + + public IAsyncEnumerable SetScanAsync(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SetScanAsync(key, pattern, pageSize, cursor, pageOffset, flags); + + public IAsyncEnumerable SortedSetScanAsync(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().SortedSetScanAsync(key, pattern, pageSize, cursor, pageOffset, flags); + + public IAsyncEnumerable VectorSetRangeEnumerateAsync(RedisKey key, RedisValue start = default, RedisValue end = default, long count = 100, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) + => GetActiveDatabase().VectorSetRangeEnumerateAsync(key, start, end, count, exclude, flags); +} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Sets.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Sets.Async.cs deleted file mode 100644 index 30ce77c588..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Sets.Async.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Threading.Tasks; - -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // Set Async operations - public Task SetCombineLengthAsync(SetOperation operation, RedisKey[] keys, long limit = 0, bool approximate = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetCombineLengthAsync(operation, keys, limit, approximate, flags); - - public Task SetAddAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetAddAsync(key, value, flags); - - public Task SetAddAsync(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetAddAsync(key, values, flags); - - public Task SetCombineAsync(SetOperation operation, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetCombineAsync(operation, first, second, flags); - - public Task SetCombineAsync(SetOperation operation, RedisKey[] keys, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetCombineAsync(operation, keys, flags); - - public Task SetCombineAndStoreAsync(SetOperation operation, RedisKey destination, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetCombineAndStoreAsync(operation, destination, first, second, flags); - - public Task SetCombineAndStoreAsync(SetOperation operation, RedisKey destination, RedisKey[] keys, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetCombineAndStoreAsync(operation, destination, keys, flags); - - public Task SetContainsAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetContainsAsync(key, value, flags); - - public Task SetContainsAsync(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetContainsAsync(key, values, flags); - - public Task SetIntersectionLengthAsync(RedisKey[] keys, long limit = 0, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetIntersectionLengthAsync(keys, limit, flags); - - public Task SetLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetLengthAsync(key, flags); - - public Task SetMembersAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetMembersAsync(key, flags); - - public Task SetMoveAsync(RedisKey source, RedisKey destination, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetMoveAsync(source, destination, value, flags); - - public Task SetPopAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetPopAsync(key, flags); - - public Task SetPopAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetPopAsync(key, count, flags); - - public Task SetRandomMemberAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetRandomMemberAsync(key, flags); - - public Task SetRandomMembersAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetRandomMembersAsync(key, count, flags); - - public Task SetRemoveAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetRemoveAsync(key, value, flags); - - public Task SetRemoveAsync(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetRemoveAsync(key, values, flags); - - public System.Collections.Generic.IAsyncEnumerable SetScanAsync(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetScanAsync(key, pattern, pageSize, cursor, pageOffset, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Sets.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Sets.cs deleted file mode 100644 index f967d38a6a..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Sets.cs +++ /dev/null @@ -1,68 +0,0 @@ -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // Set operations - public bool SetAdd(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetAdd(key, value, flags); - - public long SetCombineLength(SetOperation operation, RedisKey[] keys, long limit = 0, bool approximate = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetCombineLength(operation, keys, limit, approximate, flags); - - public long SetAdd(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetAdd(key, values, flags); - - public RedisValue[] SetCombine(SetOperation operation, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetCombine(operation, first, second, flags); - - public RedisValue[] SetCombine(SetOperation operation, RedisKey[] keys, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetCombine(operation, keys, flags); - - public long SetCombineAndStore(SetOperation operation, RedisKey destination, RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetCombineAndStore(operation, destination, first, second, flags); - - public long SetCombineAndStore(SetOperation operation, RedisKey destination, RedisKey[] keys, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetCombineAndStore(operation, destination, keys, flags); - - public bool SetContains(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetContains(key, value, flags); - - public bool[] SetContains(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetContains(key, values, flags); - - public long SetIntersectionLength(RedisKey[] keys, long limit = 0, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetIntersectionLength(keys, limit, flags); - - public long SetLength(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetLength(key, flags); - - public RedisValue[] SetMembers(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetMembers(key, flags); - - public bool SetMove(RedisKey source, RedisKey destination, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetMove(source, destination, value, flags); - - public RedisValue SetPop(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetPop(key, flags); - - public RedisValue[] SetPop(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetPop(key, count, flags); - - public RedisValue SetRandomMember(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetRandomMember(key, flags); - - public RedisValue[] SetRandomMembers(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetRandomMembers(key, count, flags); - - public bool SetRemove(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetRemove(key, value, flags); - - public long SetRemove(RedisKey key, RedisValue[] values, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetRemove(key, values, flags); - - public System.Collections.Generic.IEnumerable SetScan(RedisKey key, RedisValue pattern, int pageSize, CommandFlags flags) - => GetActiveDatabase().SetScan(key, pattern, pageSize, flags); - - public System.Collections.Generic.IEnumerable SetScan(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SetScan(key, pattern, pageSize, cursor, pageOffset, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.SortedSets.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.SortedSets.Async.cs deleted file mode 100644 index e89cb3ae68..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.SortedSets.Async.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System.Threading.Tasks; - -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // SortedSet Async operations - public Task SortedSetAddAsync(RedisKey key, RedisValue member, double score, CommandFlags flags) - => GetActiveDatabase().SortedSetAddAsync(key, member, score, flags); - - public Task SortedSetAddAsync(RedisKey key, RedisValue member, double score, When when, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetAddAsync(key, member, score, when, flags); - - public Task SortedSetAddAsync(RedisKey key, RedisValue member, double score, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetAddAsync(key, member, score, when, flags); - - public Task SortedSetAddAsync(RedisKey key, SortedSetEntry[] values, CommandFlags flags) - => GetActiveDatabase().SortedSetAddAsync(key, values, flags); - - public Task SortedSetAddAsync(RedisKey key, SortedSetEntry[] values, When when, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetAddAsync(key, values, when, flags); - - public Task SortedSetAddAsync(RedisKey key, SortedSetEntry[] values, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetAddAsync(key, values, when, flags); - - public Task SortedSetCombineAsync(SetOperation operation, RedisKey[] keys, double[]? weights = null, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetCombineAsync(operation, keys, weights, aggregate, flags); - - public Task SortedSetCombineWithScoresAsync(SetOperation operation, RedisKey[] keys, double[]? weights = null, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetCombineWithScoresAsync(operation, keys, weights, aggregate, flags); - - public Task SortedSetCombineAndStoreAsync(SetOperation operation, RedisKey destination, RedisKey first, RedisKey second, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetCombineAndStoreAsync(operation, destination, first, second, aggregate, flags); - - public Task SortedSetCombineAndStoreAsync(SetOperation operation, RedisKey destination, RedisKey[] keys, double[]? weights = null, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetCombineAndStoreAsync(operation, destination, keys, weights, aggregate, flags); - - public Task SortedSetDecrementAsync(RedisKey key, RedisValue member, double value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetDecrementAsync(key, member, value, flags); - - public Task SortedSetIncrementAsync(RedisKey key, RedisValue member, double value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetIncrementAsync(key, member, value, flags); - - public Task SortedSetIncrementAsync(RedisKey key, RedisValue member, double value, ValueCondition when, CommandFlags flags) - => GetActiveDatabase().SortedSetIncrementAsync(key, member, value, when, flags); - - public Task SortedSetIntersectionLengthAsync(RedisKey[] keys, long limit = 0, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetIntersectionLengthAsync(keys, limit, flags); - - public Task SortedSetLengthAsync(RedisKey key, double min = double.NegativeInfinity, double max = double.PositiveInfinity, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetLengthAsync(key, min, max, exclude, flags); - - public Task SortedSetLengthByValueAsync(RedisKey key, RedisValue min, RedisValue max, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetLengthByValueAsync(key, min, max, exclude, flags); - - public Task SortedSetRandomMemberAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRandomMemberAsync(key, flags); - - public Task SortedSetRandomMembersAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRandomMembersAsync(key, count, flags); - - public Task SortedSetRandomMembersWithScoresAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRandomMembersWithScoresAsync(key, count, flags); - - public Task SortedSetRangeByRankAsync(RedisKey key, long start = 0, long stop = -1, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRangeByRankAsync(key, start, stop, order, flags); - - public Task SortedSetRangeAndStoreAsync(RedisKey sourceKey, RedisKey destinationKey, RedisValue start, RedisValue stop, SortedSetOrder sortedSetOrder = SortedSetOrder.ByRank, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long? take = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRangeAndStoreAsync(sourceKey, destinationKey, start, stop, sortedSetOrder, exclude, order, skip, take, flags); - - public Task SortedSetRangeByRankWithScoresAsync(RedisKey key, long start = 0, long stop = -1, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRangeByRankWithScoresAsync(key, start, stop, order, flags); - - public Task SortedSetRangeByScoreAsync(RedisKey key, double start = double.NegativeInfinity, double stop = double.PositiveInfinity, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRangeByScoreAsync(key, start, stop, exclude, order, skip, take, flags); - - public Task SortedSetRangeByScoreWithScoresAsync(RedisKey key, double start = double.NegativeInfinity, double stop = double.PositiveInfinity, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRangeByScoreWithScoresAsync(key, start, stop, exclude, order, skip, take, flags); - - public Task SortedSetRangeByValueAsync(RedisKey key, RedisValue min, RedisValue max, Exclude exclude, long skip, long take = -1, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRangeByValueAsync(key, min, max, exclude, skip, take, flags); - - public Task SortedSetRangeByValueAsync(RedisKey key, RedisValue min = default, RedisValue max = default, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRangeByValueAsync(key, min, max, exclude, order, skip, take, flags); - - public Task SortedSetRankAsync(RedisKey key, RedisValue member, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRankAsync(key, member, order, flags); - - public Task SortedSetRemoveAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRemoveAsync(key, member, flags); - - public Task SortedSetRemoveAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRemoveAsync(key, members, flags); - - public Task SortedSetRemoveRangeByRankAsync(RedisKey key, long start, long stop, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRemoveRangeByRankAsync(key, start, stop, flags); - - public Task SortedSetRemoveRangeByScoreAsync(RedisKey key, double start, double stop, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRemoveRangeByScoreAsync(key, start, stop, exclude, flags); - - public Task SortedSetRemoveRangeByValueAsync(RedisKey key, RedisValue min, RedisValue max, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRemoveRangeByValueAsync(key, min, max, exclude, flags); - - public System.Collections.Generic.IAsyncEnumerable SortedSetScanAsync(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetScanAsync(key, pattern, pageSize, cursor, pageOffset, flags); - - public Task SortedSetScoreAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetScoreAsync(key, member, flags); - - public Task SortedSetScoresAsync(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetScoresAsync(key, members, flags); - - public Task SortedSetPopAsync(RedisKey key, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetPopAsync(key, order, flags); - - public Task SortedSetPopAsync(RedisKey key, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetPopAsync(key, count, order, flags); - - public Task SortedSetPopAsync(RedisKey[] keys, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetPopAsync(keys, count, order, flags); - - public Task SortedSetUpdateAsync(RedisKey key, RedisValue member, double score, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetUpdateAsync(key, member, score, when, flags); - - public Task SortedSetUpdateAsync(RedisKey key, SortedSetEntry[] values, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetUpdateAsync(key, values, when, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.SortedSets.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.SortedSets.cs deleted file mode 100644 index 2e237dae8b..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.SortedSets.cs +++ /dev/null @@ -1,128 +0,0 @@ -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // SortedSet operations - public bool SortedSetAdd(RedisKey key, RedisValue member, double score, CommandFlags flags) - => GetActiveDatabase().SortedSetAdd(key, member, score, flags); - - public bool SortedSetAdd(RedisKey key, RedisValue member, double score, When when, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetAdd(key, member, score, when, flags); - - public bool SortedSetAdd(RedisKey key, RedisValue member, double score, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetAdd(key, member, score, when, flags); - - public long SortedSetAdd(RedisKey key, SortedSetEntry[] values, CommandFlags flags) - => GetActiveDatabase().SortedSetAdd(key, values, flags); - - public long SortedSetAdd(RedisKey key, SortedSetEntry[] values, When when, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetAdd(key, values, when, flags); - - public long SortedSetAdd(RedisKey key, SortedSetEntry[] values, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetAdd(key, values, when, flags); - - public RedisValue[] SortedSetCombine(SetOperation operation, RedisKey[] keys, double[]? weights = null, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetCombine(operation, keys, weights, aggregate, flags); - - public SortedSetEntry[] SortedSetCombineWithScores(SetOperation operation, RedisKey[] keys, double[]? weights = null, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetCombineWithScores(operation, keys, weights, aggregate, flags); - - public long SortedSetCombineAndStore(SetOperation operation, RedisKey destination, RedisKey first, RedisKey second, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetCombineAndStore(operation, destination, first, second, aggregate, flags); - - public long SortedSetCombineAndStore(SetOperation operation, RedisKey destination, RedisKey[] keys, double[]? weights = null, Aggregate aggregate = Aggregate.Sum, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetCombineAndStore(operation, destination, keys, weights, aggregate, flags); - - public double SortedSetDecrement(RedisKey key, RedisValue member, double value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetDecrement(key, member, value, flags); - - public double SortedSetIncrement(RedisKey key, RedisValue member, double value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetIncrement(key, member, value, flags); - - public double? SortedSetIncrement(RedisKey key, RedisValue member, double value, ValueCondition when, CommandFlags flags) - => GetActiveDatabase().SortedSetIncrement(key, member, value, when, flags); - - public long SortedSetIntersectionLength(RedisKey[] keys, long limit = 0, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetIntersectionLength(keys, limit, flags); - - public long SortedSetLength(RedisKey key, double min = double.NegativeInfinity, double max = double.PositiveInfinity, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetLength(key, min, max, exclude, flags); - - public long SortedSetLengthByValue(RedisKey key, RedisValue min, RedisValue max, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetLengthByValue(key, min, max, exclude, flags); - - public RedisValue SortedSetRandomMember(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRandomMember(key, flags); - - public RedisValue[] SortedSetRandomMembers(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRandomMembers(key, count, flags); - - public SortedSetEntry[] SortedSetRandomMembersWithScores(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRandomMembersWithScores(key, count, flags); - - public RedisValue[] SortedSetRangeByRank(RedisKey key, long start = 0, long stop = -1, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRangeByRank(key, start, stop, order, flags); - - public long SortedSetRangeAndStore(RedisKey sourceKey, RedisKey destinationKey, RedisValue start, RedisValue stop, SortedSetOrder sortedSetOrder = SortedSetOrder.ByRank, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long? take = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRangeAndStore(sourceKey, destinationKey, start, stop, sortedSetOrder, exclude, order, skip, take, flags); - - public SortedSetEntry[] SortedSetRangeByRankWithScores(RedisKey key, long start = 0, long stop = -1, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRangeByRankWithScores(key, start, stop, order, flags); - - public RedisValue[] SortedSetRangeByScore(RedisKey key, double start = double.NegativeInfinity, double stop = double.PositiveInfinity, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRangeByScore(key, start, stop, exclude, order, skip, take, flags); - - public SortedSetEntry[] SortedSetRangeByScoreWithScores(RedisKey key, double start = double.NegativeInfinity, double stop = double.PositiveInfinity, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRangeByScoreWithScores(key, start, stop, exclude, order, skip, take, flags); - - public RedisValue[] SortedSetRangeByValue(RedisKey key, RedisValue min, RedisValue max, Exclude exclude, long skip, long take = -1, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRangeByValue(key, min, max, exclude, skip, take, flags); - - public RedisValue[] SortedSetRangeByValue(RedisKey key, RedisValue min = default, RedisValue max = default, Exclude exclude = Exclude.None, Order order = Order.Ascending, long skip = 0, long take = -1, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRangeByValue(key, min, max, exclude, order, skip, take, flags); - - public long? SortedSetRank(RedisKey key, RedisValue member, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRank(key, member, order, flags); - - public bool SortedSetRemove(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRemove(key, member, flags); - - public long SortedSetRemove(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRemove(key, members, flags); - - public long SortedSetRemoveRangeByRank(RedisKey key, long start, long stop, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRemoveRangeByRank(key, start, stop, flags); - - public long SortedSetRemoveRangeByScore(RedisKey key, double start, double stop, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRemoveRangeByScore(key, start, stop, exclude, flags); - - public long SortedSetRemoveRangeByValue(RedisKey key, RedisValue min, RedisValue max, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetRemoveRangeByValue(key, min, max, exclude, flags); - - public System.Collections.Generic.IEnumerable SortedSetScan(RedisKey key, RedisValue pattern, int pageSize, CommandFlags flags) - => GetActiveDatabase().SortedSetScan(key, pattern, pageSize, flags); - - public System.Collections.Generic.IEnumerable SortedSetScan(RedisKey key, RedisValue pattern = default, int pageSize = RedisBase.CursorUtils.DefaultLibraryPageSize, long cursor = RedisBase.CursorUtils.Origin, int pageOffset = 0, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetScan(key, pattern, pageSize, cursor, pageOffset, flags); - - public double? SortedSetScore(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetScore(key, member, flags); - - public double?[] SortedSetScores(RedisKey key, RedisValue[] members, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetScores(key, members, flags); - - public SortedSetEntry? SortedSetPop(RedisKey key, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetPop(key, order, flags); - - public SortedSetEntry[] SortedSetPop(RedisKey key, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetPop(key, count, order, flags); - - public SortedSetPopResult SortedSetPop(RedisKey[] keys, long count, Order order = Order.Ascending, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetPop(keys, count, order, flags); - - public bool SortedSetUpdate(RedisKey key, RedisValue member, double score, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetUpdate(key, member, score, when, flags); - - public long SortedSetUpdate(RedisKey key, SortedSetEntry[] values, SortedSetWhen when = SortedSetWhen.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortedSetUpdate(key, values, when, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Streams.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Streams.Async.cs deleted file mode 100644 index 252d33e315..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Streams.Async.cs +++ /dev/null @@ -1,143 +0,0 @@ -using System; -using System.Threading.Tasks; - -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // Stream Async operations - public Task StreamAutoClaimIdsOnlyAsync(RedisKey key, RedisValue consumerGroup, RedisValue claimingConsumer, long minIdleTimeInMs, RedisValue startAtId, int? count = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAutoClaimIdsOnlyAsync(key, consumerGroup, claimingConsumer, minIdleTimeInMs, startAtId, count, flags); - - public Task StreamAutoClaimAsync(RedisKey key, RedisValue consumerGroup, RedisValue claimingConsumer, long minIdleTimeInMs, RedisValue startAtId, int? count = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAutoClaimAsync(key, consumerGroup, claimingConsumer, minIdleTimeInMs, startAtId, count, flags); - - public Task StreamAddAsync(RedisKey key, RedisValue streamField, RedisValue streamValue, RedisValue? messageId = null, int? maxLength = null, bool useApproximateMaxLength = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAddAsync(key, streamField, streamValue, messageId, maxLength, useApproximateMaxLength, flags); - - public Task StreamAddAsync(RedisKey key, RedisValue streamField, RedisValue streamValue, RedisValue? messageId = null, long? maxLength = null, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAddAsync(key, streamField, streamValue, messageId, maxLength, useApproximateMaxLength, limit, trimMode, flags); - - public Task StreamAddAsync(RedisKey key, NameValueEntry[] streamPairs, RedisValue? messageId = null, int? maxLength = null, bool useApproximateMaxLength = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAddAsync(key, streamPairs, messageId, maxLength, useApproximateMaxLength, flags); - - public Task StreamAddAsync(RedisKey key, NameValueEntry[] streamPairs, RedisValue? messageId = null, long? maxLength = null, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAddAsync(key, streamPairs, messageId, maxLength, useApproximateMaxLength, limit, trimMode, flags); - - public Task StreamAddAsync(RedisKey key, RedisValue streamField, RedisValue streamValue, StreamIdempotentId idempotentId, long? maxLength = null, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAddAsync(key, streamField, streamValue, idempotentId, maxLength, useApproximateMaxLength, limit, trimMode, flags); - - public Task StreamAddAsync(RedisKey key, NameValueEntry[] streamPairs, StreamIdempotentId idempotentId, long? maxLength = null, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAddAsync(key, streamPairs, idempotentId, maxLength, useApproximateMaxLength, limit, trimMode, flags); - - public Task StreamClaimAsync(RedisKey key, RedisValue consumerGroup, RedisValue claimingConsumer, long minIdleTimeInMs, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamClaimAsync(key, consumerGroup, claimingConsumer, minIdleTimeInMs, messageIds, flags); - - public Task StreamClaimIdsOnlyAsync(RedisKey key, RedisValue consumerGroup, RedisValue claimingConsumer, long minIdleTimeInMs, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamClaimIdsOnlyAsync(key, consumerGroup, claimingConsumer, minIdleTimeInMs, messageIds, flags); - - public Task StreamConfigureAsync(RedisKey key, StreamConfiguration configuration, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamConfigureAsync(key, configuration, flags); - - public Task StreamConsumerGroupSetPositionAsync(RedisKey key, RedisValue groupName, RedisValue position, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamConsumerGroupSetPositionAsync(key, groupName, position, flags); - - public Task StreamConsumerInfoAsync(RedisKey key, RedisValue groupName, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamConsumerInfoAsync(key, groupName, flags); - - public Task StreamCreateConsumerGroupAsync(RedisKey key, RedisValue groupName, RedisValue? position, CommandFlags flags) - => GetActiveDatabase().StreamCreateConsumerGroupAsync(key, groupName, position, flags); - - public Task StreamCreateConsumerGroupAsync(RedisKey key, RedisValue groupName, RedisValue? position = null, bool createStream = true, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamCreateConsumerGroupAsync(key, groupName, position, createStream, flags); - - public Task StreamDeleteAsync(RedisKey key, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamDeleteAsync(key, messageIds, flags); - - public Task StreamDeleteAsync(RedisKey key, RedisValue[] messageIds, StreamTrimMode mode, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamDeleteAsync(key, messageIds, mode, flags); - - public Task StreamDeleteConsumerAsync(RedisKey key, RedisValue groupName, RedisValue consumerName, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamDeleteConsumerAsync(key, groupName, consumerName, flags); - - public Task StreamDeleteConsumerGroupAsync(RedisKey key, RedisValue groupName, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamDeleteConsumerGroupAsync(key, groupName, flags); - - public Task StreamGroupInfoAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamGroupInfoAsync(key, flags); - - public Task StreamInfoAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamInfoAsync(key, flags); - - public Task StreamLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamLengthAsync(key, flags); - - public Task StreamPendingAsync(RedisKey key, RedisValue groupName, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamPendingAsync(key, groupName, flags); - - public Task StreamPendingMessagesAsync(RedisKey key, RedisValue groupName, int count, RedisValue consumerName, RedisValue? minId = null, RedisValue? maxId = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamPendingMessagesAsync(key, groupName, count, consumerName, minId, maxId, flags); - - public Task StreamPendingMessagesAsync(RedisKey key, RedisValue groupName, int count, RedisValue consumerName, RedisValue? minId = null, RedisValue? maxId = null, long? idle = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamPendingMessagesAsync(key, groupName, count, consumerName, minId, maxId, idle, flags); - - public Task StreamRangeAsync(RedisKey key, RedisValue? minId = null, RedisValue? maxId = null, int? count = null, Order messageOrder = Order.Ascending, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamRangeAsync(key, minId, maxId, count, messageOrder, flags); - - public Task StreamReadAsync(RedisKey key, RedisValue position, int? count = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamReadAsync(key, position, count, flags); - - public Task StreamReadAsync(StreamPosition[] streamPositions, int? countPerStream = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamReadAsync(streamPositions, countPerStream, flags); - - public Task StreamReadAsync(StreamPosition[] streamPositions, int? countPerStream = null, int? maxCount = null, int? maxSize = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamReadAsync(streamPositions, countPerStream, maxCount, maxSize, flags); - - public Task StreamReadGroupAsync(RedisKey key, RedisValue groupName, RedisValue consumerName, RedisValue? position, int? count, CommandFlags flags) - => GetActiveDatabase().StreamReadGroupAsync(key, groupName, consumerName, position, count, flags); - - public Task StreamReadGroupAsync(RedisKey key, RedisValue groupName, RedisValue consumerName, RedisValue? position = null, int? count = null, bool noAck = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamReadGroupAsync(key, groupName, consumerName, position, count, noAck, flags); - - public Task StreamReadGroupAsync(RedisKey key, RedisValue groupName, RedisValue consumerName, RedisValue? position = null, int? count = null, bool noAck = false, TimeSpan? blockingTimeout = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamReadGroupAsync(key, groupName, consumerName, position, count, noAck, blockingTimeout, flags); - - public Task StreamReadGroupAsync(StreamPosition[] streamPositions, RedisValue groupName, RedisValue consumerName, int? countPerStream, CommandFlags flags) - => GetActiveDatabase().StreamReadGroupAsync(streamPositions, groupName, consumerName, countPerStream, flags); - - public Task StreamReadGroupAsync(StreamPosition[] streamPositions, RedisValue groupName, RedisValue consumerName, int? countPerStream = null, bool noAck = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamReadGroupAsync(streamPositions, groupName, consumerName, countPerStream, noAck, flags); - - public Task StreamReadGroupAsync(StreamPosition[] streamPositions, RedisValue groupName, RedisValue consumerName, int? countPerStream = null, bool noAck = false, TimeSpan? blockingTimeout = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamReadGroupAsync(streamPositions, groupName, consumerName, countPerStream, noAck, blockingTimeout, flags); - - public Task StreamReadGroupAsync(StreamPosition[] streamPositions, RedisValue groupName, RedisValue consumerName, int? countPerStream = null, bool noAck = false, TimeSpan? claimMinIdleTime = null, int? maxCount = null, int? maxSize = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamReadGroupAsync(streamPositions, groupName, consumerName, countPerStream, noAck, claimMinIdleTime, maxCount, maxSize, flags); - - public Task StreamTrimAsync(RedisKey key, int maxLength, bool useApproximateMaxLength = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamTrimAsync(key, maxLength, useApproximateMaxLength, flags); - - public Task StreamTrimAsync(RedisKey key, long maxLength, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamTrimAsync(key, maxLength, useApproximateMaxLength, limit, trimMode, flags); - - public Task StreamTrimByMinIdAsync(RedisKey key, RedisValue minId, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamTrimByMinIdAsync(key, minId, useApproximateMaxLength, limit, trimMode, flags); - - public Task StreamAcknowledgeAsync(RedisKey key, RedisValue groupName, RedisValue messageId, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAcknowledgeAsync(key, groupName, messageId, flags); - - public Task StreamAcknowledgeAsync(RedisKey key, RedisValue groupName, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAcknowledgeAsync(key, groupName, messageIds, flags); - - public Task StreamNegativeAcknowledgeAsync(RedisKey key, RedisValue groupName, StreamNackMode mode, RedisValue messageId, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamNegativeAcknowledgeAsync(key, groupName, mode, messageId, flags); - - public Task StreamNegativeAcknowledgeAsync(RedisKey key, RedisValue groupName, StreamNackMode mode, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamNegativeAcknowledgeAsync(key, groupName, mode, messageIds, flags); - - public Task StreamAcknowledgeAndDeleteAsync(RedisKey key, RedisValue groupName, StreamTrimMode mode, RedisValue messageId, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAcknowledgeAndDeleteAsync(key, groupName, mode, messageId, flags); - - public Task StreamAcknowledgeAndDeleteAsync(RedisKey key, RedisValue groupName, StreamTrimMode mode, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAcknowledgeAndDeleteAsync(key, groupName, mode, messageIds, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Streams.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Streams.cs deleted file mode 100644 index d2efe63306..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Streams.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System; - -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // Stream operations - public StreamAutoClaimIdsOnlyResult StreamAutoClaimIdsOnly(RedisKey key, RedisValue consumerGroup, RedisValue claimingConsumer, long minIdleTimeInMs, RedisValue startAtId, int? count = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAutoClaimIdsOnly(key, consumerGroup, claimingConsumer, minIdleTimeInMs, startAtId, count, flags); - - public StreamAutoClaimResult StreamAutoClaim(RedisKey key, RedisValue consumerGroup, RedisValue claimingConsumer, long minIdleTimeInMs, RedisValue startAtId, int? count = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAutoClaim(key, consumerGroup, claimingConsumer, minIdleTimeInMs, startAtId, count, flags); - - public RedisValue StreamAdd(RedisKey key, RedisValue streamField, RedisValue streamValue, RedisValue? messageId = null, int? maxLength = null, bool useApproximateMaxLength = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAdd(key, streamField, streamValue, messageId, maxLength, useApproximateMaxLength, flags); - - public RedisValue StreamAdd(RedisKey key, RedisValue streamField, RedisValue streamValue, RedisValue? messageId = null, long? maxLength = null, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAdd(key, streamField, streamValue, messageId, maxLength, useApproximateMaxLength, limit, trimMode, flags); - - public RedisValue StreamAdd(RedisKey key, NameValueEntry[] streamPairs, RedisValue? messageId = null, int? maxLength = null, bool useApproximateMaxLength = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAdd(key, streamPairs, messageId, maxLength, useApproximateMaxLength, flags); - - public RedisValue StreamAdd(RedisKey key, NameValueEntry[] streamPairs, RedisValue? messageId = null, long? maxLength = null, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAdd(key, streamPairs, messageId, maxLength, useApproximateMaxLength, limit, trimMode, flags); - - public RedisValue StreamAdd(RedisKey key, RedisValue streamField, RedisValue streamValue, StreamIdempotentId idempotentId, long? maxLength = null, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAdd(key, streamField, streamValue, idempotentId, maxLength, useApproximateMaxLength, limit, trimMode, flags); - - public RedisValue StreamAdd(RedisKey key, NameValueEntry[] streamPairs, StreamIdempotentId idempotentId, long? maxLength = null, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAdd(key, streamPairs, idempotentId, maxLength, useApproximateMaxLength, limit, trimMode, flags); - - public StreamEntry[] StreamClaim(RedisKey key, RedisValue consumerGroup, RedisValue claimingConsumer, long minIdleTimeInMs, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamClaim(key, consumerGroup, claimingConsumer, minIdleTimeInMs, messageIds, flags); - - public RedisValue[] StreamClaimIdsOnly(RedisKey key, RedisValue consumerGroup, RedisValue claimingConsumer, long minIdleTimeInMs, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamClaimIdsOnly(key, consumerGroup, claimingConsumer, minIdleTimeInMs, messageIds, flags); - - public void StreamConfigure(RedisKey key, StreamConfiguration configuration, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamConfigure(key, configuration, flags); - - public bool StreamConsumerGroupSetPosition(RedisKey key, RedisValue groupName, RedisValue position, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamConsumerGroupSetPosition(key, groupName, position, flags); - - public StreamConsumerInfo[] StreamConsumerInfo(RedisKey key, RedisValue groupName, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamConsumerInfo(key, groupName, flags); - - public bool StreamCreateConsumerGroup(RedisKey key, RedisValue groupName, RedisValue? position, CommandFlags flags) - => GetActiveDatabase().StreamCreateConsumerGroup(key, groupName, position, flags); - - public bool StreamCreateConsumerGroup(RedisKey key, RedisValue groupName, RedisValue? position = null, bool createStream = true, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamCreateConsumerGroup(key, groupName, position, createStream, flags); - - public long StreamDelete(RedisKey key, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamDelete(key, messageIds, flags); - - public long StreamDeleteConsumer(RedisKey key, RedisValue groupName, RedisValue consumerName, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamDeleteConsumer(key, groupName, consumerName, flags); - - public bool StreamDeleteConsumerGroup(RedisKey key, RedisValue groupName, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamDeleteConsumerGroup(key, groupName, flags); - - public StreamGroupInfo[] StreamGroupInfo(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamGroupInfo(key, flags); - - public StreamInfo StreamInfo(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamInfo(key, flags); - - public long StreamLength(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamLength(key, flags); - - public StreamPendingInfo StreamPending(RedisKey key, RedisValue groupName, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamPending(key, groupName, flags); - - public StreamPendingMessageInfo[] StreamPendingMessages(RedisKey key, RedisValue groupName, int count, RedisValue consumerName, RedisValue? minId = null, RedisValue? maxId = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamPendingMessages(key, groupName, count, consumerName, minId, maxId, flags); - - public StreamEntry[] StreamRange(RedisKey key, RedisValue? minId = null, RedisValue? maxId = null, int? count = null, Order messageOrder = Order.Ascending, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamRange(key, minId, maxId, count, messageOrder, flags); - - public StreamEntry[] StreamRead(RedisKey key, RedisValue position, int? count = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamRead(key, position, count, flags); - - public RedisStream[] StreamRead(StreamPosition[] streamPositions, int? countPerStream = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamRead(streamPositions, countPerStream, flags); - - public RedisStream[] StreamRead(StreamPosition[] streamPositions, int? countPerStream = null, int? maxCount = null, int? maxSize = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamRead(streamPositions, countPerStream, maxCount, maxSize, flags); - - public StreamEntry[] StreamReadGroup(RedisKey key, RedisValue groupName, RedisValue consumerName, RedisValue? position, int? count, CommandFlags flags) - => GetActiveDatabase().StreamReadGroup(key, groupName, consumerName, position, count, flags); - - public StreamEntry[] StreamReadGroup(RedisKey key, RedisValue groupName, RedisValue consumerName, RedisValue? position = null, int? count = null, bool noAck = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamReadGroup(key, groupName, consumerName, position, count, noAck, flags); - - public RedisStream[] StreamReadGroup(StreamPosition[] streamPositions, RedisValue groupName, RedisValue consumerName, int? countPerStream, CommandFlags flags) - => GetActiveDatabase().StreamReadGroup(streamPositions, groupName, consumerName, countPerStream, flags); - - public RedisStream[] StreamReadGroup(StreamPosition[] streamPositions, RedisValue groupName, RedisValue consumerName, int? countPerStream = null, bool noAck = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamReadGroup(streamPositions, groupName, consumerName, countPerStream, noAck, flags); - - public long StreamTrim(RedisKey key, int maxLength, bool useApproximateMaxLength = false, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamTrim(key, maxLength, useApproximateMaxLength, flags); - - public long StreamAcknowledge(RedisKey key, RedisValue groupName, RedisValue messageId, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAcknowledge(key, groupName, messageId, flags); - - public long StreamAcknowledge(RedisKey key, RedisValue groupName, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAcknowledge(key, groupName, messageIds, flags); - - public long StreamNegativeAcknowledge(RedisKey key, RedisValue groupName, StreamNackMode mode, RedisValue messageId, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamNegativeAcknowledge(key, groupName, mode, messageId, flags); - - public long StreamNegativeAcknowledge(RedisKey key, RedisValue groupName, StreamNackMode mode, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamNegativeAcknowledge(key, groupName, mode, messageIds, flags); - - public StreamTrimResult StreamAcknowledgeAndDelete(RedisKey key, RedisValue groupName, StreamTrimMode mode, RedisValue messageId, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAcknowledgeAndDelete(key, groupName, mode, messageId, flags); - - public StreamTrimResult[] StreamAcknowledgeAndDelete(RedisKey key, RedisValue groupName, StreamTrimMode mode, RedisValue[] messageIds, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamAcknowledgeAndDelete(key, groupName, mode, messageIds, flags); - - public StreamTrimResult[] StreamDelete(RedisKey key, RedisValue[] messageIds, StreamTrimMode mode, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamDelete(key, messageIds, mode, flags); - - public StreamPendingMessageInfo[] StreamPendingMessages(RedisKey key, RedisValue groupName, int count, RedisValue consumerName, RedisValue? minId = null, RedisValue? maxId = null, long? idle = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamPendingMessages(key, groupName, count, consumerName, minId, maxId, idle, flags); - - public StreamEntry[] StreamReadGroup(RedisKey key, RedisValue groupName, RedisValue consumerName, RedisValue? position = null, int? count = null, bool noAck = false, TimeSpan? blockingTimeout = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamReadGroup(key, groupName, consumerName, position, count, noAck, blockingTimeout, flags); - - public RedisStream[] StreamReadGroup(StreamPosition[] streamPositions, RedisValue groupName, RedisValue consumerName, int? countPerStream = null, bool noAck = false, TimeSpan? blockingTimeout = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamReadGroup(streamPositions, groupName, consumerName, countPerStream, noAck, blockingTimeout, flags); - - public RedisStream[] StreamReadGroup(StreamPosition[] streamPositions, RedisValue groupName, RedisValue consumerName, int? countPerStream = null, bool noAck = false, TimeSpan? claimMinIdleTime = null, int? maxCount = null, int? maxSize = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamReadGroup(streamPositions, groupName, consumerName, countPerStream, noAck, claimMinIdleTime, maxCount, maxSize, flags); - - public long StreamTrim(RedisKey key, long maxLength, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamTrim(key, maxLength, useApproximateMaxLength, limit, trimMode, flags); - - public long StreamTrimByMinId(RedisKey key, RedisValue minId, bool useApproximateMaxLength = false, long? limit = null, StreamTrimMode trimMode = StreamTrimMode.KeepReferences, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StreamTrimByMinId(key, minId, useApproximateMaxLength, limit, trimMode, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Strings.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Strings.Async.cs deleted file mode 100644 index 5e4447b3bd..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Strings.Async.cs +++ /dev/null @@ -1,125 +0,0 @@ -using System; -using System.Threading.Tasks; - -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // String Async operations - public Task StringAppendAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringAppendAsync(key, value, flags); - - public Task StringBitCountAsync(RedisKey key, long start, long end, CommandFlags flags) - => GetActiveDatabase().StringBitCountAsync(key, start, end, flags); - - public Task StringBitCountAsync(RedisKey key, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringBitCountAsync(key, start, end, indexType, flags); - - public Task StringBitOperationAsync(Bitwise operation, RedisKey destination, RedisKey first, RedisKey second = default, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringBitOperationAsync(operation, destination, first, second, flags); - - public Task StringBitOperationAsync(Bitwise operation, RedisKey destination, RedisKey[] keys, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringBitOperationAsync(operation, destination, keys, flags); - - public Task StringBitPositionAsync(RedisKey key, bool bit, long start, long end, CommandFlags flags) - => GetActiveDatabase().StringBitPositionAsync(key, bit, start, end, flags); - - public Task StringBitPositionAsync(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringBitPositionAsync(key, bit, start, end, indexType, flags); - - public Task StringDecrementAsync(RedisKey key, long value = 1, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringDecrementAsync(key, value, flags); - - public Task StringDecrementAsync(RedisKey key, double value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringDecrementAsync(key, value, flags); - - public Task StringDeleteAsync(RedisKey key, ValueCondition when, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringDeleteAsync(key, when, flags); - - public Task StringDigestAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringDigestAsync(key, flags); - - public Task StringGetAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringGetAsync(key, flags); - - public Task StringGetAsync(RedisKey[] keys, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringGetAsync(keys, flags); - - public Task?> StringGetLeaseAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringGetLeaseAsync(key, flags); - - public Task StringGetBitAsync(RedisKey key, long offset, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringGetBitAsync(key, offset, flags); - - public Task StringGetRangeAsync(RedisKey key, long start, long end, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringGetRangeAsync(key, start, end, flags); - - public Task StringGetSetAsync(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringGetSetAsync(key, value, flags); - - public Task StringGetSetExpiryAsync(RedisKey key, TimeSpan? expiry, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringGetSetExpiryAsync(key, expiry, flags); - - public Task StringGetSetExpiryAsync(RedisKey key, DateTime expiry, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringGetSetExpiryAsync(key, expiry, flags); - - public Task StringGetDeleteAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringGetDeleteAsync(key, flags); - - public Task StringGetWithExpiryAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringGetWithExpiryAsync(key, flags); - - public Task StringIncrementAsync(RedisKey key, long value = 1, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringIncrementAsync(key, value, flags); - - public Task StringIncrementAsync(RedisKey key, double value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringIncrementAsync(key, value, flags); - - public Task> StringIncrementAsync(RedisKey key, long value, Expiration expiry, long? lowerBound = null, long? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringIncrementAsync(key, value, expiry, lowerBound, upperBound, options, flags); - - public Task> StringIncrementAsync(RedisKey key, double value, Expiration expiry, double? lowerBound = null, double? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringIncrementAsync(key, value, expiry, lowerBound, upperBound, options, flags); - - public Task StringLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringLengthAsync(key, flags); - - public Task StringLongestCommonSubsequenceAsync(RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringLongestCommonSubsequenceAsync(first, second, flags); - - public Task StringLongestCommonSubsequenceLengthAsync(RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringLongestCommonSubsequenceLengthAsync(first, second, flags); - - public Task StringLongestCommonSubsequenceWithMatchesAsync(RedisKey first, RedisKey second, long minLength = 0, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringLongestCommonSubsequenceWithMatchesAsync(first, second, minLength, flags); - - public Task StringSetAsync(RedisKey key, RedisValue value, TimeSpan? expiry, When when) - => GetActiveDatabase().StringSetAsync(key, value, expiry, when); - - public Task StringSetAsync(RedisKey key, RedisValue value, TimeSpan? expiry, When when, CommandFlags flags) - => GetActiveDatabase().StringSetAsync(key, value, expiry, when, flags); - - public Task StringSetAsync(RedisKey key, RedisValue value, TimeSpan? expiry, bool keepTtl, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringSetAsync(key, value, expiry, keepTtl, when, flags); - - public Task StringSetAsync(RedisKey key, RedisValue value, Expiration expiry = default, ValueCondition when = default, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringSetAsync(key, value, expiry, when, flags); - - public Task StringSetAsync(System.Collections.Generic.KeyValuePair[] values, When when, CommandFlags flags) - => GetActiveDatabase().StringSetAsync(values, when, flags); - - public Task StringSetAsync(System.Collections.Generic.KeyValuePair[] values, When when = When.Always, Expiration expiry = default, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringSetAsync(values, when, expiry, flags); - - public Task StringSetAndGetAsync(RedisKey key, RedisValue value, TimeSpan? expiry, When when, CommandFlags flags) - => GetActiveDatabase().StringSetAndGetAsync(key, value, expiry, when, flags); - - public Task StringSetAndGetAsync(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringSetAndGetAsync(key, value, expiry, keepTtl, when, flags); - - public Task StringSetBitAsync(RedisKey key, long offset, bool bit, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringSetBitAsync(key, offset, bit, flags); - - public Task StringSetRangeAsync(RedisKey key, long offset, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringSetRangeAsync(key, offset, value, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Strings.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Strings.cs deleted file mode 100644 index b29d4a771a..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.Strings.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System; - -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // String operations - public long StringAppend(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringAppend(key, value, flags); - - public long StringBitCount(RedisKey key, long start, long end, CommandFlags flags) - => GetActiveDatabase().StringBitCount(key, start, end, flags); - - public long StringBitCount(RedisKey key, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringBitCount(key, start, end, indexType, flags); - - public long StringBitOperation(Bitwise operation, RedisKey destination, RedisKey first, RedisKey second = default, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringBitOperation(operation, destination, first, second, flags); - - public long StringBitOperation(Bitwise operation, RedisKey destination, RedisKey[] keys, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringBitOperation(operation, destination, keys, flags); - - public long StringBitPosition(RedisKey key, bool bit, long start, long end, CommandFlags flags) - => GetActiveDatabase().StringBitPosition(key, bit, start, end, flags); - - public long StringBitPosition(RedisKey key, bool bit, long start = 0, long end = -1, StringIndexType indexType = StringIndexType.Byte, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringBitPosition(key, bit, start, end, indexType, flags); - - public long StringDecrement(RedisKey key, long value = 1, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringDecrement(key, value, flags); - - public double StringDecrement(RedisKey key, double value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringDecrement(key, value, flags); - - public bool StringDelete(RedisKey key, ValueCondition when, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringDelete(key, when, flags); - - public ValueCondition? StringDigest(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringDigest(key, flags); - - public RedisValue StringGet(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringGet(key, flags); - - public RedisValue[] StringGet(RedisKey[] keys, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringGet(keys, flags); - - public Lease? StringGetLease(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringGetLease(key, flags); - - public bool StringGetBit(RedisKey key, long offset, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringGetBit(key, offset, flags); - - public RedisValue StringGetRange(RedisKey key, long start, long end, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringGetRange(key, start, end, flags); - - public RedisValue StringGetSet(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringGetSet(key, value, flags); - - public RedisValue StringGetSetExpiry(RedisKey key, TimeSpan? expiry, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringGetSetExpiry(key, expiry, flags); - - public RedisValue StringGetSetExpiry(RedisKey key, DateTime expiry, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringGetSetExpiry(key, expiry, flags); - - public RedisValue StringGetDelete(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringGetDelete(key, flags); - - public RedisValueWithExpiry StringGetWithExpiry(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringGetWithExpiry(key, flags); - - public long StringIncrement(RedisKey key, long value = 1, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringIncrement(key, value, flags); - - public double StringIncrement(RedisKey key, double value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringIncrement(key, value, flags); - - public StringIncrementResult StringIncrement(RedisKey key, long value, Expiration expiry, long? lowerBound = null, long? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringIncrement(key, value, expiry, lowerBound, upperBound, options, flags); - - public StringIncrementResult StringIncrement(RedisKey key, double value, Expiration expiry, double? lowerBound = null, double? upperBound = null, IncrementOptions options = IncrementOptions.None, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringIncrement(key, value, expiry, lowerBound, upperBound, options, flags); - - public long StringLength(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringLength(key, flags); - - public string? StringLongestCommonSubsequence(RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringLongestCommonSubsequence(first, second, flags); - - public long StringLongestCommonSubsequenceLength(RedisKey first, RedisKey second, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringLongestCommonSubsequenceLength(first, second, flags); - - public LCSMatchResult StringLongestCommonSubsequenceWithMatches(RedisKey first, RedisKey second, long minLength = 0, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringLongestCommonSubsequenceWithMatches(first, second, minLength, flags); - - public bool StringSet(RedisKey key, RedisValue value, TimeSpan? expiry, When when) - => GetActiveDatabase().StringSet(key, value, expiry, when); - - public bool StringSet(RedisKey key, RedisValue value, TimeSpan? expiry, When when, CommandFlags flags) - => GetActiveDatabase().StringSet(key, value, expiry, when, flags); - - public bool StringSet(RedisKey key, RedisValue value, TimeSpan? expiry, bool keepTtl, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringSet(key, value, expiry, keepTtl, when, flags); - - public bool StringSet(RedisKey key, RedisValue value, Expiration expiry = default, ValueCondition when = default, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringSet(key, value, expiry, when, flags); - - public bool StringSet(System.Collections.Generic.KeyValuePair[] values, When when, CommandFlags flags) - => GetActiveDatabase().StringSet(values, when, flags); - - public bool StringSet(System.Collections.Generic.KeyValuePair[] values, When when = When.Always, Expiration expiry = default, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringSet(values, when, expiry, flags); - - public RedisValue StringSetAndGet(RedisKey key, RedisValue value, TimeSpan? expiry, When when, CommandFlags flags) - => GetActiveDatabase().StringSetAndGet(key, value, expiry, when, flags); - - public RedisValue StringSetAndGet(RedisKey key, RedisValue value, TimeSpan? expiry = null, bool keepTtl = false, When when = When.Always, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringSetAndGet(key, value, expiry, keepTtl, when, flags); - - public bool StringSetBit(RedisKey key, long offset, bool bit, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringSetBit(key, offset, bit, flags); - - public RedisValue StringSetRange(RedisKey key, long offset, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().StringSetRange(key, offset, value, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.VectorSets.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.VectorSets.Async.cs deleted file mode 100644 index b72b6ced16..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.VectorSets.Async.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Threading.Tasks; - -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // VectorSet Async operations - public Task VectorSetAddAsync(RedisKey key, VectorSetAddRequest request, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetAddAsync(key, request, flags); - - public Task VectorSetLengthAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetLengthAsync(key, flags); - - public Task VectorSetDimensionAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetDimensionAsync(key, flags); - - public Task?> VectorSetGetApproximateVectorAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetGetApproximateVectorAsync(key, member, flags); - - public Task VectorSetGetAttributesJsonAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetGetAttributesJsonAsync(key, member, flags); - - public Task VectorSetInfoAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetInfoAsync(key, flags); - - public Task VectorSetContainsAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetContainsAsync(key, member, flags); - - public Task?> VectorSetGetLinksAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetGetLinksAsync(key, member, flags); - - public Task?> VectorSetGetLinksWithScoresAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetGetLinksWithScoresAsync(key, member, flags); - - public Task VectorSetRandomMemberAsync(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetRandomMemberAsync(key, flags); - - public Task VectorSetRandomMembersAsync(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetRandomMembersAsync(key, count, flags); - - public Task VectorSetRemoveAsync(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetRemoveAsync(key, member, flags); - - public Task VectorSetSetAttributesJsonAsync(RedisKey key, RedisValue member, string attributesJson, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetSetAttributesJsonAsync(key, member, attributesJson, flags); - - public Task?> VectorSetSimilaritySearchAsync(RedisKey key, VectorSetSimilaritySearchRequest query, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetSimilaritySearchAsync(key, query, flags); - - public Task?> VectorSetRangeAsync(RedisKey key, RedisValue start = default, RedisValue end = default, long count = -1, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetRangeAsync(key, start, end, count, exclude, flags); - - public System.Collections.Generic.IAsyncEnumerable VectorSetRangeEnumerateAsync(RedisKey key, RedisValue start = default, RedisValue end = default, long count = 100, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetRangeEnumerateAsync(key, start, end, count, exclude, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.VectorSets.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.VectorSets.cs deleted file mode 100644 index e7a4164db0..0000000000 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.VectorSets.cs +++ /dev/null @@ -1,53 +0,0 @@ -namespace StackExchange.Redis.Availability; - -internal sealed partial class MultiGroupDatabase -{ - // VectorSet operations - public bool VectorSetAdd(RedisKey key, VectorSetAddRequest request, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetAdd(key, request, flags); - - public long VectorSetLength(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetLength(key, flags); - - public int VectorSetDimension(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetDimension(key, flags); - - public Lease? VectorSetGetApproximateVector(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetGetApproximateVector(key, member, flags); - - public string? VectorSetGetAttributesJson(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetGetAttributesJson(key, member, flags); - - public VectorSetInfo? VectorSetInfo(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetInfo(key, flags); - - public bool VectorSetContains(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetContains(key, member, flags); - - public Lease? VectorSetGetLinks(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetGetLinks(key, member, flags); - - public Lease? VectorSetGetLinksWithScores(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetGetLinksWithScores(key, member, flags); - - public RedisValue VectorSetRandomMember(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetRandomMember(key, flags); - - public RedisValue[] VectorSetRandomMembers(RedisKey key, long count, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetRandomMembers(key, count, flags); - - public bool VectorSetRemove(RedisKey key, RedisValue member, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetRemove(key, member, flags); - - public bool VectorSetSetAttributesJson(RedisKey key, RedisValue member, string attributesJson, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetSetAttributesJson(key, member, attributesJson, flags); - - public Lease? VectorSetSimilaritySearch(RedisKey key, VectorSetSimilaritySearchRequest query, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetSimilaritySearch(key, query, flags); - - public Lease VectorSetRange(RedisKey key, RedisValue start = default, RedisValue end = default, long count = -1, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetRange(key, start, end, count, exclude, flags); - - public System.Collections.Generic.IEnumerable VectorSetRangeEnumerate(RedisKey key, RedisValue start = default, RedisValue end = default, long count = 100, Exclude exclude = Exclude.None, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().VectorSetRangeEnumerate(key, start, end, count, exclude, flags); -} diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs index e896228c61..8f83a249fc 100644 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs @@ -1,9 +1,16 @@ using System; using System.Threading; +using System.Threading.Tasks; using StackExchange.Redis.Interfaces; namespace StackExchange.Redis.Availability; +// The bulk of IDatabase/IDatabaseAsync is generated by [AutoDatabase]: every command is captured into a +// state struct and replayed against the currently-active member via the Execute/ExecuteAsync funnels below +// (so a call always lands on whichever member is active *now*). Only the members the generator deliberately +// skips - the Wait family, IsConnected, the batch/transaction factories, and the streaming scans (see +// MultiGroupDatabase.Scans.cs) - plus the properties and internal plumbing are hand-written here. +[AutoDatabase] internal sealed partial class MultiGroupDatabase(MultiGroupMultiplexer parent, int database, object? asyncState) : IDatabase, IInternalDatabaseAsync { @@ -33,7 +40,27 @@ DatabaseFeatureFlags IInternalDatabaseAsync.GetFeatures(out string name) // member, for use by members that have an obvious trivial result when disconnected private IDatabase? TryGetActiveDatabase() => parent.TryGetActive()?.GetDatabase(database, asyncState); - // Core methods + // ---- [AutoDatabase] funnels --------------------------------------------------------------------- + // the generated explicit interface implementations funnel every captured call through these: we simply + // forward to the currently-active member. Unlike RetryDatabase there is no key mapping to apply (we are + // not rewriting keys), so the captured state's Map/UnMapper is never invoked. + private TResult Execute(in TState state, AutoDatabaseSyncOperation operation) + where TState : struct, IRedisArgs + => operation(in state, GetActiveDatabase()); + + private void Execute(in TState state, AutoDatabaseSyncOperation operation) + where TState : struct, IRedisArgs + => operation(in state, GetActiveDatabase()); + + private Task ExecuteAsync(in TState state, AutoDatabaseAsyncOperation operation) + where TState : struct, IRedisArgs + => operation(in state, GetActiveDatabase()); + + private Task ExecuteAsync(in TState state, AutoDatabaseAsyncOperation operation) + where TState : struct, IRedisArgs + => operation(in state, GetActiveDatabase()); + + // ---- members the generator deliberately skips (see AutoDatabaseGenerator.SkipMethod) ------------- public IBatch CreateBatch(object? asyncState = null) => GetActiveDatabase().CreateBatch(asyncState); @@ -42,76 +69,20 @@ public ITransaction CreateTransaction(object? asyncState = null) ITransactionAsync IDatabaseAsync.CreateTransaction(object? asyncState) => CreateTransaction(asyncState); - public void KeyMigrate(RedisKey key, System.Net.EndPoint toServer, int toDatabase = 0, int timeoutMilliseconds = 0, MigrateOptions migrateOptions = MigrateOptions.None, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().KeyMigrate(key, toServer, toDatabase, timeoutMilliseconds, migrateOptions, flags); - - public RedisValue DebugObject(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().DebugObject(key, flags); - - public System.Net.EndPoint? IdentifyEndpoint(RedisKey key = default, CommandFlags flags = CommandFlags.None) - => TryGetActiveDatabase()?.IdentifyEndpoint(key, flags); - + // status probe, not a server round-trip: trivially "not connected" when there is no active member public bool IsConnected(RedisKey key, CommandFlags flags = CommandFlags.None) => TryGetActiveDatabase()?.IsConnected(key, flags) ?? false; - public System.TimeSpan Ping(CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().Ping(flags); - - public long Publish(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().Publish(channel, message, flags); - - public RedisResult Execute(string command, params object[] args) - => GetActiveDatabase().Execute(command, args); - - public RedisResult Execute(string command, System.Collections.Generic.ICollection args, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().Execute(command, args, flags); - - public RedisResult ScriptEvaluate(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ScriptEvaluate(script, keys, values, flags); - - public RedisResult ScriptEvaluate(byte[] hash, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ScriptEvaluate(hash, keys, values, flags); - - public RedisResult ScriptEvaluate(LuaScript script, object? parameters = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ScriptEvaluate(script, parameters, flags); - - public RedisResult ScriptEvaluate(LoadedLuaScript script, object? parameters = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ScriptEvaluate(script, parameters, flags); - - public RedisResult ScriptEvaluateReadOnly(string script, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ScriptEvaluateReadOnly(script, keys, values, flags); - - public RedisResult ScriptEvaluateReadOnly(byte[] hash, RedisKey[]? keys = null, RedisValue[]? values = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().ScriptEvaluateReadOnly(hash, keys, values, flags); - - public bool LockExtend(RedisKey key, RedisValue value, TimeSpan expiry, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().LockExtend(key, value, expiry, flags); - - public RedisValue LockQuery(RedisKey key, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().LockQuery(key, flags); - - public bool LockRelease(RedisKey key, RedisValue value, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().LockRelease(key, value, flags); - - public bool LockTake(RedisKey key, RedisValue value, TimeSpan expiry, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().LockTake(key, value, expiry, flags); - - public RedisValue[] Sort(RedisKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, RedisValue by = default, RedisValue[]? get = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().Sort(key, skip, take, order, sortType, by, get, flags); - - public long SortAndStore(RedisKey destination, RedisKey key, long skip = 0, long take = -1, Order order = Order.Ascending, SortType sortType = SortType.Numeric, RedisValue by = default, RedisValue[]? get = null, CommandFlags flags = CommandFlags.None) - => GetActiveDatabase().SortAndStore(destination, key, skip, take, order, sortType, by, get, flags); - - // IRedisAsync methods - public bool TryWait(System.Threading.Tasks.Task task) - => GetActiveDatabase().TryWait(task); - - public void Wait(System.Threading.Tasks.Task task) - => GetActiveDatabase().Wait(task); + // routing lookup against the currently-active member; null / NoEndpoint when the group has none + public System.Net.EndPoint? IdentifyEndpoint(RedisKey key = default, CommandFlags flags = CommandFlags.None) + => TryGetActiveDatabase()?.IdentifyEndpoint(key, flags); - public T Wait(System.Threading.Tasks.Task task) - => GetActiveDatabase().Wait(task); + public Task IdentifyEndpointAsync(RedisKey key = default, CommandFlags flags = CommandFlags.None) + => TryGetActiveDatabase()?.IdentifyEndpointAsync(key, flags) ?? MultiGroupMultiplexer.NoEndpoint; - public void WaitAll(params System.Threading.Tasks.Task[] tasks) - => GetActiveDatabase().WaitAll(tasks); + // the Wait family operates on caller-supplied Tasks, not server calls + public bool TryWait(Task task) => GetActiveDatabase().TryWait(task); + public void Wait(Task task) => GetActiveDatabase().Wait(task); + public T Wait(Task task) => GetActiveDatabase().Wait(task); + public void WaitAll(params Task[] tasks) => GetActiveDatabase().WaitAll(tasks); } diff --git a/src/StackExchange.Redis/Availability/RetryDatabase.cs b/src/StackExchange.Redis/Availability/RetryDatabase.cs index ee97e030f7..2df728e92b 100644 --- a/src/StackExchange.Redis/Availability/RetryDatabase.cs +++ b/src/StackExchange.Redis/Availability/RetryDatabase.cs @@ -55,7 +55,9 @@ ITransactionAsync IDatabaseAsync.CreateTransaction(object? asyncState) // policy will live here in due course; for now it is a straight pass-through. // async counterparts (Task / Task); these get their own retry/failover policy in due course. - private async Task ExecuteAsync(TState state, Func> operation) + // note: the state cannot be taken by `in` here (async methods forbid by-ref parameters), and we need a + // mutable copy anyway for Map; only the projection takes it by readonly-ref, avoiding a copy per attempt + private async Task ExecuteAsync(TState state, AutoDatabaseAsyncOperation operation) where TState : struct, IRedisArgs { state.Map(this); @@ -69,7 +71,7 @@ private async Task ExecuteAsync(TState state, Func ExecuteAsync(TState state, Func(TState state, Func operation) + private async Task ExecuteAsync(TState state, AutoDatabaseAsyncOperation operation) where TState : struct, IRedisArgs { state.Map(this); @@ -94,7 +96,7 @@ private async Task ExecuteAsync(TState state, Func(TState state, Func _inner.IsConnected(key, flags); - System.Collections.Generic.IAsyncEnumerable IDatabaseAsync.HashScanAsync(RedisKey key, RedisValue pattern, int pageSize, long cursor, int pageOffset, CommandFlags flags) => throw new NotImplementedException(); - System.Collections.Generic.IAsyncEnumerable IDatabaseAsync.HashScanNoValuesAsync(RedisKey key, RedisValue pattern, int pageSize, long cursor, int pageOffset, CommandFlags flags) => throw new NotImplementedException(); - System.Collections.Generic.IAsyncEnumerable IDatabaseAsync.SetScanAsync(RedisKey key, RedisValue pattern, int pageSize, long cursor, int pageOffset, CommandFlags flags) => throw new NotImplementedException(); - System.Collections.Generic.IAsyncEnumerable IDatabaseAsync.VectorSetRangeEnumerateAsync(RedisKey key, RedisValue start, RedisValue end, long count, Exclude exclude, CommandFlags flags) => throw new NotImplementedException(); - System.Collections.Generic.IAsyncEnumerable IDatabaseAsync.SortedSetScanAsync(RedisKey key, RedisValue pattern, int pageSize, long cursor, int pageOffset, CommandFlags flags) => throw new NotImplementedException(); + // routing lookup, not a replayable server command - forward straight through (no retry) + Task IDatabaseAsync.IdentifyEndpointAsync(RedisKey key, CommandFlags flags) => _inner.IdentifyEndpointAsync(key, flags); + + // Scans are streaming/cursored, so they can't be captured-and-replayed as a single unit; rather than + // failing outright we forward straight to the inner database - giving up retry, but keeping the scan working. + System.Collections.Generic.IAsyncEnumerable IDatabaseAsync.HashScanAsync(RedisKey key, RedisValue pattern, int pageSize, long cursor, int pageOffset, CommandFlags flags) => _inner.HashScanAsync(key, pattern, pageSize, cursor, pageOffset, flags); + System.Collections.Generic.IAsyncEnumerable IDatabaseAsync.HashScanNoValuesAsync(RedisKey key, RedisValue pattern, int pageSize, long cursor, int pageOffset, CommandFlags flags) => _inner.HashScanNoValuesAsync(key, pattern, pageSize, cursor, pageOffset, flags); + System.Collections.Generic.IAsyncEnumerable IDatabaseAsync.SetScanAsync(RedisKey key, RedisValue pattern, int pageSize, long cursor, int pageOffset, CommandFlags flags) => _inner.SetScanAsync(key, pattern, pageSize, cursor, pageOffset, flags); + System.Collections.Generic.IAsyncEnumerable IDatabaseAsync.VectorSetRangeEnumerateAsync(RedisKey key, RedisValue start, RedisValue end, long count, Exclude exclude, CommandFlags flags) => _inner.VectorSetRangeEnumerateAsync(key, start, end, count, exclude, flags); + System.Collections.Generic.IAsyncEnumerable IDatabaseAsync.SortedSetScanAsync(RedisKey key, RedisValue pattern, int pageSize, long cursor, int pageOffset, CommandFlags flags) => _inner.SortedSetScanAsync(key, pattern, pageSize, cursor, pageOffset, flags); RedisKey IRedisArgsMutator.Map(RedisKey key) => key; diff --git a/src/StackExchange.Redis/Availability/RetryTransaction.cs b/src/StackExchange.Redis/Availability/RetryTransaction.cs index 592bc24f90..38c4fcb908 100644 --- a/src/StackExchange.Redis/Availability/RetryTransaction.cs +++ b/src/StackExchange.Redis/Availability/RetryTransaction.cs @@ -43,7 +43,7 @@ public RetryTransaction(IDatabaseAsync source, RetryController controller, objec // the generated explicit interface implementations funnel every builder call through these two // overloads, capturing the arguments in a generated state struct plus a cacheable static projection // (no per-call closure). Here we simply *record* them and return a durable proxy task. - private Task ExecuteAsync(TState state, Func> operation) + private Task ExecuteAsync(in TState state, AutoDatabaseAsyncOperation operation) where TState : struct, IRedisArgs { CheckNotExecuted(); @@ -52,7 +52,7 @@ private Task ExecuteAsync(TState state, Func(TState state, Func operation) + private Task ExecuteAsync(in TState state, AutoDatabaseAsyncOperation operation) where TState : struct, IRedisArgs { CheckNotExecuted(); @@ -152,11 +152,11 @@ private sealed class RecordedOp : IRecordedOp where TState : struct, IRedisArgs { private readonly TState _state; - private readonly Func> _operation; + private readonly AutoDatabaseAsyncOperation _operation; private readonly TaskCompletionSource _proxy = new(TaskCreationOptions.RunContinuationsAsynchronously); private Task? _attempt; - public RecordedOp(TState state, Func> operation) + public RecordedOp(in TState state, AutoDatabaseAsyncOperation operation) { _state = state; _operation = operation; @@ -164,7 +164,7 @@ public RecordedOp(TState state, Func> oper public Task Proxy => _proxy.Task; - public void Replay(IDatabaseAsync inner) => _attempt = _operation(_state, inner); + public void Replay(IDatabaseAsync inner) => _attempt = _operation(in _state, inner); public void ForwardSuccess() { @@ -189,11 +189,11 @@ private sealed class RecordedVoidOp : IRecordedOp where TState : struct, IRedisArgs { private readonly TState _state; - private readonly Func _operation; + private readonly AutoDatabaseAsyncOperation _operation; private readonly TaskCompletionSource _proxy = new(TaskCreationOptions.RunContinuationsAsynchronously); private Task? _attempt; - public RecordedVoidOp(TState state, Func operation) + public RecordedVoidOp(in TState state, AutoDatabaseAsyncOperation operation) { _state = state; _operation = operation; @@ -201,7 +201,7 @@ public RecordedVoidOp(TState state, Func operation public Task Proxy => _proxy.Task; - public void Replay(IDatabaseAsync inner) => _attempt = _operation(_state, inner); + public void Replay(IDatabaseAsync inner) => _attempt = _operation(in _state, inner); public void ForwardSuccess() { @@ -254,6 +254,9 @@ public void ForwardSuccess() bool IDatabaseAsync.IsConnected(RedisKey key, CommandFlags flags) => _source.IsConnected(key, flags); + // routing lookup against the underlying database, not a queued transaction operation + Task IDatabaseAsync.IdentifyEndpointAsync(RedisKey key, CommandFlags flags) => _source.IdentifyEndpointAsync(key, flags); + // nested transactions are not supported (mirrors RedisTransaction) ITransactionAsync IDatabaseAsync.CreateTransaction(object? asyncState) => throw new NotSupportedException("Nested transactions are not supported"); From b28ba2e71ba49c6614901045763c03c0d5ed7017 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 29 Jul 2026 09:57:37 +0100 Subject: [PATCH 25/33] improve key/channel mapping machinery --- .../AutoDatabaseGenerator.cs | 59 +++++++++++++++-- src/StackExchange.Redis/AutoDatabase.cs | 66 ++++++++++++++----- .../Availability/MultiGroupDatabase.cs | 8 +-- .../Availability/RetryDatabase.cs | 25 ++++--- .../Availability/RetryTransaction.cs | 8 +-- 5 files changed, 123 insertions(+), 43 deletions(-) diff --git a/eng/StackExchange.Redis.Build/AutoDatabaseGenerator.cs b/eng/StackExchange.Redis.Build/AutoDatabaseGenerator.cs index 9f4c5f0226..a8df6744c6 100644 --- a/eng/StackExchange.Redis.Build/AutoDatabaseGenerator.cs +++ b/eng/StackExchange.Redis.Build/AutoDatabaseGenerator.cs @@ -9,7 +9,7 @@ using ParamInfo = (string Name, string Type, Microsoft.CodeAnalysis.RefKind RefKind, bool IsParams, bool IsOptional, bool HasDefault, string? Default); using MethodInfo = (string Name, string ReturnType, StackExchange.Redis.Build.BasicArray<(string Name, string Type, Microsoft.CodeAnalysis.RefKind RefKind, bool IsParams, bool IsOptional, bool HasDefault, string? Default)> Parameters, StackExchange.Redis.Build.BasicArray TypeArgs); using InterfaceInfo = (string Name, string Namespace, StackExchange.Redis.Build.AutoDatabaseGenerator.KnownInterfaces KnownType, StackExchange.Redis.Build.BasicArray<(string Name, string ReturnType, StackExchange.Redis.Build.BasicArray<(string Name, string Type, Microsoft.CodeAnalysis.RefKind RefKind, bool IsParams, bool IsOptional, bool HasDefault, string? Default)> Parameters, StackExchange.Redis.Build.BasicArray TypeArgs)> Methods); -using ClassInfo = (string Name, string Namespace, StackExchange.Redis.Build.AutoDatabaseGenerator.KnownInterfaces Interfaces); +using ClassInfo = (string Name, string Namespace, StackExchange.Redis.Build.AutoDatabaseGenerator.KnownInterfaces Interfaces, bool IsMutator); namespace StackExchange.Redis.Build; @@ -48,6 +48,24 @@ private static bool FastIndexFilter(InterfaceDeclarationSyntax decl) // limit to private static bool FastClassFilter(ClassDeclarationSyntax decl) // limit to IDatabase, IDatabaseAsync => decl.AttributeLists.Any(x => x.Attributes.Any(x => x.Name.ToString() is "AutoDatabase" or "AutoDatabaseAttribute")); + // does this auto-database rewrite keys/channels? if not, the captured-args structs need no mapping members + private static bool IsMutatorInterface(INamedTypeSymbol symbol) => + symbol is + { + TypeKind: TypeKind.Interface, + Name: "IRedisArgsMutator", + ContainingType: null, + ContainingNamespace: + { + Name: "Redis", + ContainingNamespace: + { + Name: "StackExchange", + ContainingNamespace.IsGlobalNamespace: true + } + } + }; + private static bool IsOurInterface(INamedTypeSymbol symbol) => symbol is { @@ -174,8 +192,19 @@ static bool HasAutoDatabaseAttrib(INamedTypeSymbol symbol) if (known is KnownInterfaces.None) return default; // nothing to do! + // AllInterfaces (not Interfaces) so an inherited/explicit implementation still counts + bool isMutator = false; + foreach (var iFace in cls.AllInterfaces) + { + if (IsMutatorInterface(iFace)) + { + isMutator = true; + break; + } + } + var ns = cls.ContainingNamespace.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); - return (cls.Name, ns, known); + return (cls.Name, ns, known, isMutator); } [Flags] @@ -418,22 +447,38 @@ void AppendTupleTypes() bool needsMap = TupleNeedsMap(raw); var parameters = raw.Span; - // fields are mutable: Map rewrites key/channel fields and Flags has a setter - writer.NewLine().NewLine().Append("private struct _tuple").Append(i).Append("("); + // key/channel-bearing fields are mutable only when something can rewrite them (i.e. the + // owning database is an IRedisArgsMutator); otherwise every field is readonly + // a non-mutator's captured args never change after capture, so the struct itself can be + // readonly - which also guarantees no defensive copies when read through an `in` ref + writer.NewLine().NewLine().Append(cls.IsMutator ? "private struct _tuple" : "private readonly struct _tuple").Append(i).Append("("); for (int p = 0; p < parameters.Length; p++) { if (p != 0) writer.Append(", "); writer.Append(parameters[p].Type).Append(" arg").Append(p); } - writer.Append(") : global::StackExchange.Redis.IRedisArgs"); + // captured args are a plain struct unless the owning database rewrites keys, in which + // case the mapping members - and the interface that carries them - are emitted below + writer.Append(")"); + if (cls.IsMutator) + { + writer.Append(" : global::StackExchange.Redis.IMappableRedisArgs"); + } writer.NewLine().Append("{").Indent(); for (int p = 0; p < parameters.Length; p++) { - writer.NewLine().Append("public ").Append(NeedsMap(parameters[p].Type) ? "" : "readonly ").Append(parameters[p].Type) + writer.NewLine().Append("public ").Append(cls.IsMutator && NeedsMap(parameters[p].Type) ? "" : "readonly ").Append(parameters[p].Type) .Append(" Arg").Append(p).Append(" = arg").Append(p).Append(";"); } + if (!cls.IsMutator) + { + // nothing rewrites keys here, so there is no Map/UnMapper to emit at all + writer.Outdent().NewLine().Append("}"); + continue; + } + // Map rewrites each scalar key/channel field directly, and defers container or // loosely-typed fields to a matching Map extension method writer.NewLine().Append("public void Map(global::StackExchange.Redis.IRedisArgsMutator mutator)") @@ -458,7 +503,7 @@ void AppendTupleTypes() writer.Outdent().NewLine().Append("}"); } - if (allReturns.Count is not 0) + if (cls.IsMutator && allReturns.Count is not 0) { // sorted for deterministic (cache-stable) output var ordered = new List(allReturns); diff --git a/src/StackExchange.Redis/AutoDatabase.cs b/src/StackExchange.Redis/AutoDatabase.cs index f6d09edfed..407a2e3fa8 100644 --- a/src/StackExchange.Redis/AutoDatabase.cs +++ b/src/StackExchange.Redis/AutoDatabase.cs @@ -13,35 +13,52 @@ internal sealed class AutoDatabaseAttribute : Attribute { } -internal interface IRedisArgs +// Implemented by a generated captured-arguments struct only when the owning auto-database implements +// IRedisArgsMutator - i.e. only when something actually rewrites keys/channels. Everything else captures its +// arguments into a plain readonly struct with no interface at all; a funnel that wants to map opts in by +// constraining its TState to this (which is how KeyPrefixedDatabase will work once it moves onto +// [AutoDatabase]). +// +// Map mutates the struct in place, so a funnel that maps MUST take its state BY VALUE - never by `in`, because +// calling a non-readonly member through an `in` reference silently takes a defensive copy, mutates that, and +// throws it away. Invoke it via RedisArgsMutatorExtensions.MapInPlace, which makes that mistake a compile +// error (CS8329) instead of a silent loss of prefixing. (`ref` on the funnel is not an alternative: the +// generated call site passes a constructed temporary, which `ref` rejects with CS1510, and async funnels +// cannot have by-ref parameters at all.) The by-value copy is not waste here - a mapping funnel needs its own +// mutable copy by definition. Only non-mapping funnels take `in`, and their structs are readonly, so for them +// no defensive copy is even possible. +internal interface IMappableRedisArgs { + [Obsolete("Use MapInPlace instead; calling Map directly does nothing when the state is held by `in`.")] void Map(IRedisArgsMutator mutator); + object? UnMapper { get; } } -// The projections used by the auto-database funnels. These exist (rather than Func<,,>/Action<,>) purely to -// pass the captured state by readonly-reference: some of the generated state structs are chunky (a dozen-plus -// arguments), and by-value would copy the whole thing on every call. Note that a lambda can only bind to -// these if it declares the modifier - the generator emits `static (in state, inner) => ...` accordingly. -// The projections used by the auto-database funnels; there are exactly four real shapes, so each is named -// rather than being expressed generically over the target/return type. This lets the type system encode the +// The projections used by the auto-database funnels. There are exactly four real shapes, so each is named +// rather than being expressed generically over the target/return type: that lets the type system encode the // invariants (sync goes to IDatabase, async goes to IDatabaseAsync and returns a Task) instead of leaving // nonsense combinations like "sync database, Task result" expressible. // +// They exist at all (rather than Func<,,>/Action<,>) purely to pass the captured state by readonly-reference: +// some of the generated state structs are chunky (a dozen-plus arguments), and by-value would copy the whole +// thing on every call. Note that a lambda only binds to an `in` parameter if it says so - the generator emits +// `static (in state, inner) => ...` accordingly. +// // TState is necessarily invariant: it is passed by readonly-ref, and variant type parameters cannot carry // the struct constraint. TResult is covariant on the sync projection (it is the direct return); it cannot be // on the async one, because there it appears inside the invariant Task. internal delegate void AutoDatabaseSyncOperation(in TState state, IDatabase database) - where TState : struct, IRedisArgs; + where TState : struct; internal delegate TResult AutoDatabaseSyncOperation(in TState state, IDatabase database) - where TState : struct, IRedisArgs; + where TState : struct; internal delegate Task AutoDatabaseAsyncOperation(in TState state, IDatabaseAsync database) - where TState : struct, IRedisArgs; + where TState : struct; internal delegate Task AutoDatabaseAsyncOperation(in TState state, IDatabaseAsync database) - where TState : struct, IRedisArgs; + where TState : struct; internal interface IRedisArgsMutator { @@ -67,28 +84,41 @@ public static KeyValuePair Map( KeyValuePair value) => new(mutator.Map(value.Key), value.Value); + // Funnels should map via this rather than calling state.Map(mutator) directly. Mapping mutates the struct + // in place, so it is only correct on a writable variable; the `ref this` here means that applying it to an + // `in` parameter fails to compile (CS8329) instead of silently taking a defensive copy, mutating that, and + // discarding it - which would leave keys unprefixed, i.e. a wrong-keyspace bug rather than a perf nit. + // (Deliberately not named Map: an extension cannot shadow the instance member, so the guard would be lost.) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void MapInPlace(this ref TState state, IRedisArgsMutator mutator) + where TState : struct, IMappableRedisArgs +#pragma warning disable CS0618 // the one sanctioned call site: `state` is a genuine `ref`, so this mutates in place + => state.Map(mutator); +#pragma warning restore CS0618 + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static TResult UnMap(this IRedisArgsMutator mutator, in TState state, TResult result) - where TState : struct, IRedisArgs + where TState : struct, IMappableRedisArgs { + // note JIT will elide these tests using per-TResult value-type rules if (typeof(TResult) == typeof(RedisKey)) { var tmp = mutator.UnMap(Unsafe.As(ref result)); return Unsafe.As(ref tmp); } - else if (typeof(TResult) == typeof(RedisChannel)) + + if (typeof(TResult) == typeof(RedisChannel)) { var tmp = mutator.UnMap(Unsafe.As(ref result)); return Unsafe.As(ref tmp); } - else if (typeof(TResult) == typeof(RedisValue)) + + if (typeof(TResult) == typeof(RedisValue)) { return result; // never mapped } - else - { - return state.UnMapper is IRedisArgsResult unmap ? unmap.UnMap(mutator, result) : result; - } + + return state.UnMapper is IRedisArgsResult unmap ? unmap.UnMap(mutator, result) : result; } [return: NotNullIfNotNull("keys")] diff --git a/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs index 8f83a249fc..dd51c47c0d 100644 --- a/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs @@ -45,19 +45,19 @@ DatabaseFeatureFlags IInternalDatabaseAsync.GetFeatures(out string name) // forward to the currently-active member. Unlike RetryDatabase there is no key mapping to apply (we are // not rewriting keys), so the captured state's Map/UnMapper is never invoked. private TResult Execute(in TState state, AutoDatabaseSyncOperation operation) - where TState : struct, IRedisArgs + where TState : struct => operation(in state, GetActiveDatabase()); private void Execute(in TState state, AutoDatabaseSyncOperation operation) - where TState : struct, IRedisArgs + where TState : struct => operation(in state, GetActiveDatabase()); private Task ExecuteAsync(in TState state, AutoDatabaseAsyncOperation operation) - where TState : struct, IRedisArgs + where TState : struct => operation(in state, GetActiveDatabase()); private Task ExecuteAsync(in TState state, AutoDatabaseAsyncOperation operation) - where TState : struct, IRedisArgs + where TState : struct => operation(in state, GetActiveDatabase()); // ---- members the generator deliberately skips (see AutoDatabaseGenerator.SkipMethod) ------------- diff --git a/src/StackExchange.Redis/Availability/RetryDatabase.cs b/src/StackExchange.Redis/Availability/RetryDatabase.cs index 2df728e92b..dbb6064ddf 100644 --- a/src/StackExchange.Redis/Availability/RetryDatabase.cs +++ b/src/StackExchange.Redis/Availability/RetryDatabase.cs @@ -1,6 +1,4 @@ using System; -using System.Diagnostics; -using System.IO.Pipes; using System.Threading; using System.Threading.Tasks; using StackExchange.Redis.Interfaces; @@ -8,7 +6,8 @@ namespace StackExchange.Redis.Availability; [AutoDatabase] -internal partial class RetryDatabase : IDatabaseAsync, IRedisArgsMutator, IInternalDatabaseAsync +internal partial class RetryDatabase : IDatabaseAsync, IInternalDatabaseAsync + // IRedisArgsMutator <==== if we ever want to support key-mapping { // Note: we very deliberately do not include synchronous support for retry; it is inherently delay-ish @@ -55,12 +54,13 @@ ITransactionAsync IDatabaseAsync.CreateTransaction(object? asyncState) // policy will live here in due course; for now it is a straight pass-through. // async counterparts (Task / Task); these get their own retry/failover policy in due course. - // note: the state cannot be taken by `in` here (async methods forbid by-ref parameters), and we need a - // mutable copy anyway for Map; only the projection takes it by readonly-ref, avoiding a copy per attempt + // note: the state cannot be taken by `in` here (async methods forbid by-ref parameters); + // only the projection takes it by readonly-ref, avoiding a copy per attempt private async Task ExecuteAsync(TState state, AutoDatabaseAsyncOperation operation) - where TState : struct, IRedisArgs + where TState : struct { - state.Map(this); + /* key mapping, not used currently + * state.MapInPlace(this); */ int attempt = 0; TResult result; @@ -79,14 +79,17 @@ private async Task ExecuteAsync(TState state, AutoData await _controller.FailoverOrDelayAsync(delay).ConfigureAwait(false); } } + /* key mapping, not used currently // post-process results outside the loop - return this.UnMap(state, result); + return this.UnMap(state, result);*/ + return result; } private async Task ExecuteAsync(TState state, AutoDatabaseAsyncOperation operation) - where TState : struct, IRedisArgs + where TState : struct { - state.Map(this); + /* key mapping, not used currently + * state.MapInPlace(this); */ int attempt = 0; // note we need to capture this *before* the attempt - otherwise the failover could happen @@ -129,10 +132,12 @@ private async Task ExecuteAsync(TState state, AutoDatabaseAsyncOperation System.Collections.Generic.IAsyncEnumerable IDatabaseAsync.VectorSetRangeEnumerateAsync(RedisKey key, RedisValue start, RedisValue end, long count, Exclude exclude, CommandFlags flags) => _inner.VectorSetRangeEnumerateAsync(key, start, end, count, exclude, flags); System.Collections.Generic.IAsyncEnumerable IDatabaseAsync.SortedSetScanAsync(RedisKey key, RedisValue pattern, int pageSize, long cursor, int pageOffset, CommandFlags flags) => _inner.SortedSetScanAsync(key, pattern, pageSize, cursor, pageOffset, flags); + /* optional: key mapping RedisKey IRedisArgsMutator.Map(RedisKey key) => key; RedisChannel IRedisArgsMutator.Map(RedisChannel channel) => channel; RedisKey IRedisArgsMutator.UnMap(RedisKey key) => key; RedisChannel IRedisArgsMutator.UnMap(RedisChannel channel) => channel; + */ } diff --git a/src/StackExchange.Redis/Availability/RetryTransaction.cs b/src/StackExchange.Redis/Availability/RetryTransaction.cs index 38c4fcb908..c79507bd15 100644 --- a/src/StackExchange.Redis/Availability/RetryTransaction.cs +++ b/src/StackExchange.Redis/Availability/RetryTransaction.cs @@ -44,7 +44,7 @@ public RetryTransaction(IDatabaseAsync source, RetryController controller, objec // overloads, capturing the arguments in a generated state struct plus a cacheable static projection // (no per-call closure). Here we simply *record* them and return a durable proxy task. private Task ExecuteAsync(in TState state, AutoDatabaseAsyncOperation operation) - where TState : struct, IRedisArgs + where TState : struct { CheckNotExecuted(); var op = new RecordedOp(state, operation); @@ -53,7 +53,7 @@ private Task ExecuteAsync(in TState state, AutoDatabas } private Task ExecuteAsync(in TState state, AutoDatabaseAsyncOperation operation) - where TState : struct, IRedisArgs + where TState : struct { CheckNotExecuted(); var op = new RecordedVoidOp(state, operation); @@ -149,7 +149,7 @@ private interface IRecordedOp } private sealed class RecordedOp : IRecordedOp - where TState : struct, IRedisArgs + where TState : struct { private readonly TState _state; private readonly AutoDatabaseAsyncOperation _operation; @@ -186,7 +186,7 @@ public void Fault(Exception ex) } private sealed class RecordedVoidOp : IRecordedOp - where TState : struct, IRedisArgs + where TState : struct { private readonly TState _state; private readonly AutoDatabaseAsyncOperation _operation; From 95fc8eafd372398e8486dad67010c4bc3705651b Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 29 Jul 2026 12:59:58 +0100 Subject: [PATCH 26/33] fix name break --- tests/StackExchange.Redis.Tests/NamingTests.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/StackExchange.Redis.Tests/NamingTests.cs b/tests/StackExchange.Redis.Tests/NamingTests.cs index 784be86cdb..5a6e71d54b 100644 --- a/tests/StackExchange.Redis.Tests/NamingTests.cs +++ b/tests/StackExchange.Redis.Tests/NamingTests.cs @@ -168,6 +168,12 @@ private void CheckMethod(MethodInfo method, bool isAsync) { case nameof(IDatabaseAsync.IsConnected): return; + case nameof(IDatabaseAsync.CreateTransaction) when isAsync: + // IDatabaseAsync.CreateTransaction is a synchronous *factory*: it hands back an + // ITransactionAsync rather than performing an async operation, so neither the *Async + // suffix nor the Task-returning rule applies. IDatabase.CreateTransaction is unaffected + // and still goes through the checks below. + return; case nameof(IDatabase.CreateBatch): case nameof(IDatabase.CreateTransaction): case nameof(IDatabase.IdentifyEndpoint): From b0413eb66107a3a4c51892e82ab20273cd8ac82a Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 29 Jul 2026 13:17:54 +0100 Subject: [PATCH 27/33] make AA configuration API consistent (#3141) --- docs/ActiveActive.md | 216 +++++++++--- .../Availability/CircuitBreaker.cs | 40 ++- .../Availability/DatabaseExtensions.cs | 40 ++- .../HealthCheck.ConnectedProbe.cs | 23 -- .../Availability/HealthCheck.Execute.cs | 48 ++- .../HealthCheck.HealthCheckProbe.cs | 83 ----- .../HealthCheck.HealthCheckProbeContext.cs | 43 --- .../HealthCheck.HealthCheckProbePolicy.cs | 105 ------ .../Availability/HealthCheck.cs | 225 +++++++----- .../Availability/HealthCheckContext.cs | 30 ++ .../HealthCheckProbe.Connected.cs | 20 ++ .../Availability/HealthCheckProbe.None.cs | 21 ++ ....PingProbe.cs => HealthCheckProbe.Ping.cs} | 17 +- ...Probe.cs => HealthCheckProbe.StringSet.cs} | 17 +- .../Availability/HealthCheckProbe.cs | 59 ++++ .../Availability/HealthCheckProbeContext.cs | 40 +++ .../Availability/HealthCheckProbePolicy.cs | 102 ++++++ .../Availability/HealthCheckResult.cs | 26 ++ .../Availability/MultiGroupMultiplexer.cs | 100 ++++-- .../Availability/MultiGroupOptions.cs | 155 ++++++-- .../Availability/RetryController.cs | 34 +- .../Availability/RetryDatabase.cs | 2 + .../Availability/RetryPolicy.cs | 240 ++++++++++--- .../Availability/RetryResult.cs | 28 ++ .../ConfigurationOptions.cs | 21 +- .../ConnectionMultiplexer.cs | 54 ++- .../Interfaces/IConnectionGroup.cs | 6 + src/StackExchange.Redis/PhysicalConnection.cs | 5 +- .../PublicAPI/PublicAPI.Unshipped.txt | 180 ++++++---- .../StackExchange.Redis.csproj | 3 + .../AvailabilityConfigTests.cs | 333 ++++++++++++++++++ .../StackExchange.Redis.Tests/ConfigTests.cs | 2 +- .../ControllableProbe.cs | 6 +- .../HealthCheckPolicyUnitTests.cs | 2 +- .../MultiGroupTests/BasicMultiGroupTests.cs | 10 +- .../CircuitBreakerRerouteTests.cs | 8 +- .../GroupConfigResolutionTests.cs | 102 ++++++ .../RetryTests/CommandRetryPolicyUnitTests.cs | 26 +- .../RetryTests/RetryEndToEndTests.cs | 30 +- 39 files changed, 1802 insertions(+), 700 deletions(-) delete mode 100644 src/StackExchange.Redis/Availability/HealthCheck.ConnectedProbe.cs delete mode 100644 src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbe.cs delete mode 100644 src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbeContext.cs delete mode 100644 src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbePolicy.cs create mode 100644 src/StackExchange.Redis/Availability/HealthCheckContext.cs create mode 100644 src/StackExchange.Redis/Availability/HealthCheckProbe.Connected.cs create mode 100644 src/StackExchange.Redis/Availability/HealthCheckProbe.None.cs rename src/StackExchange.Redis/Availability/{HealthCheck.PingProbe.cs => HealthCheckProbe.Ping.cs} (50%) rename src/StackExchange.Redis/Availability/{HealthCheck.StringSetProbe.cs => HealthCheckProbe.StringSet.cs} (79%) create mode 100644 src/StackExchange.Redis/Availability/HealthCheckProbe.cs create mode 100644 src/StackExchange.Redis/Availability/HealthCheckProbeContext.cs create mode 100644 src/StackExchange.Redis/Availability/HealthCheckProbePolicy.cs create mode 100644 src/StackExchange.Redis/Availability/HealthCheckResult.cs create mode 100644 src/StackExchange.Redis/Availability/RetryResult.cs create mode 100644 tests/StackExchange.Redis.Tests/AvailabilityConfigTests.cs create mode 100644 tests/StackExchange.Redis.Tests/MultiGroupTests/GroupConfigResolutionTests.cs diff --git a/docs/ActiveActive.md b/docs/ActiveActive.md index 80f30c300b..e6328910e1 100644 --- a/docs/ActiveActive.md +++ b/docs/ActiveActive.md @@ -15,6 +15,53 @@ The features for Active:Active are available in the `Availability` sub-namespace using StackExchange.Redis; using StackExchange.Redis.Availability; ``` + +### How the configuration types work + +Every configurable piece in this namespace follows the same three-part shape, so once you have learned one you have learned all of them: + +1. The **policy type** (`HealthCheck`, `CircuitBreaker`, `RetryPolicy`) is **immutable** and safe to share between members and connections. It exposes a static `Default` and a static `None`. +2. Each policy has a nested **`Builder`** carrying the mutable knobs. A new `Builder` already starts from the default values, so you only set what you want to change; `Create()` validates the values and returns the policy, and a `Builder` also converts *implicitly* to its policy, so it can be assigned or passed inline. +3. **`MultiGroupOptions`** (itself immutable, with its own `Builder`) holds the group-wide defaults; the matching nullable property on `ConnectionGroupMember` overrides them per member. + +```csharp +// the same pattern, three times; each builder only mentions what differs from the default +HealthCheck healthCheck = new HealthCheck.Builder { ProbeCount = 5 }; +CircuitBreaker breaker = new CircuitBreaker.Builder { FailureRateThreshold = 25 }; +RetryPolicy retry = new RetryPolicy.Builder { MaxAttempts = 5 }; + +MultiGroupOptions options = new MultiGroupOptions.Builder +{ + HealthCheck = healthCheck, + CircuitBreaker = breaker, + RetryPolicy = retry, + HealthCheckInterval = TimeSpan.FromSeconds(2), +}; +``` + +Anything you leave out keeps its default, so a group that only wants a longer failback is just: + +```csharp +MultiGroupOptions options = new MultiGroupOptions.Builder { FailbackDelay = TimeSpan.FromMinutes(2) }; +``` + +Because the policies are immutable, there is no question of whether a change "takes effect" after connecting: to change something, build a new instance. Values are validated once, in `Create()`, which throws `ArgumentOutOfRangeException`/`ArgumentException` naming the offending builder property - so a bad `ProbeCount` or `MaxAttempts` fails at the point you configure it, not later. + +### Where each setting lives + +| Setting | Group-wide default | Per-member override | +|---------|--------------------|---------------------| +| `HealthCheck` | `MultiGroupOptions.HealthCheck` | `ConnectionGroupMember.HealthCheck` | +| `CircuitBreaker` | `MultiGroupOptions.CircuitBreaker` | `ConnectionGroupMember.CircuitBreaker`, else that member's `ConfigurationOptions.CircuitBreaker` | +| `RetryPolicy` | `MultiGroupOptions.RetryPolicy` | *(none; retry is applied per-database via `WithRetry`)* | +| `HealthCheckInterval` | `MultiGroupOptions.HealthCheckInterval` | *(none; it is the group's re-evaluation cadence)* | +| `FailbackDelay` | `MultiGroupOptions.FailbackDelay` | `ConnectionGroupMember.FailbackDelay` | +| `Weight` | *(none)* | `ConnectionGroupMember.Weight` | +| `SkipInitialHealthCheck` | *(none)* | `ConnectionGroupMember.SkipInitialHealthCheck` | + +Resolution is always "member override, else group default". Resolving a group default never writes back into your `ConfigurationOptions` - you can safely reuse one `ConfigurationOptions` for a group member and for an unrelated `Connect` without the group's policies leaking across. + +`ConfigurationOptions` carries only the two availability settings that are meaningful for a **single** connection - `CircuitBreaker` and `RetryPolicy`. `HealthCheck` is a group-only concept and lives on `ConnectionGroupMember`. The library automatically selects the best available endpoint based on: 1. **Availability** - Connected endpoints are always preferred over disconnected ones @@ -213,22 +260,22 @@ The Active:Active feature includes configurable health checking to monitor the h ### Basic Health Check Configuration -Health checks are configured globally for all members using the `MultiGroupOptions` parameter: +Health checks are configured for all members via `MultiGroupOptions`, and can be overridden per member. Every knob is shown here for orientation, with **the value it already defaults to** - in real code you would set only the ones you want to change: ```csharp -var healthCheck = new HealthCheck +HealthCheck healthCheck = new HealthCheck.Builder { - Interval = TimeSpan.FromSeconds(5), // How often to check health - ProbeCount = 3, // Maximum number of probe attempts per check - ProbeTimeout = TimeSpan.FromSeconds(3), // Timeout for each probe attempt + ProbeCount = 3, // Maximum number of probe attempts per check + ProbeTimeout = TimeSpan.FromSeconds(3), // Timeout for each probe attempt ProbeInterval = TimeSpan.FromMilliseconds(500), // Delay between failed probes - Probe = HealthCheckProbe.Ping, // Which probe type to use + Probe = HealthCheckProbe.Ping, // Which probe type to use ProbePolicy = HealthCheckProbePolicy.AllSuccess // Evaluation policy }; -var options = new MultiGroupOptions +MultiGroupOptions options = new MultiGroupOptions.Builder { - HealthCheck = healthCheck + HealthCheck = healthCheck, + HealthCheckInterval = TimeSpan.FromSeconds(5), // How often checks run (a group-level concern) }; ConnectionGroupMember[] members = [ @@ -239,6 +286,8 @@ ConnectionGroupMember[] members = [ await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members, options); ``` +Note that **how often** checks run is `MultiGroupOptions.HealthCheckInterval`, not a property of the check: it is the cadence at which the group re-evaluates *all* members, so a per-member value would be meaningless. + ### Using Default Health Checks If you don't specify a health check, the system uses sensible defaults: @@ -248,39 +297,63 @@ If you don't specify a health check, the system uses sensible defaults: await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members); // Equivalent to: -var options = new MultiGroupOptions -{ - HealthCheck = HealthCheck.Default -}; +MultiGroupOptions options = new MultiGroupOptions.Builder { HealthCheck = HealthCheck.Default }; await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members, options); ``` -You can also clone and customize the default: +A new `Builder` starts from the defaults, so customizing means setting only what differs: ```csharp -var customHealthCheck = HealthCheck.Default.Clone(); -customHealthCheck.Interval = TimeSpan.FromSeconds(15); -customHealthCheck.ProbeCount = 5; +HealthCheck customHealthCheck = new HealthCheck.Builder { ProbeCount = 5 }; // everything else defaulted -var options = new MultiGroupOptions +MultiGroupOptions options = new MultiGroupOptions.Builder { - HealthCheck = customHealthCheck + HealthCheck = customHealthCheck, + HealthCheckInterval = TimeSpan.FromSeconds(15), }; ``` +There is also a `Builder(policy)` overload, for when you want to start from an instance that *isn't* the default - for example, adjusting a policy you were handed: + +```csharp +// take the group's configured check and probe it harder for one member +HealthCheck stricter = new HealthCheck.Builder(conn.Options.HealthCheck) { ProbeCount = 9 }; +``` + ### Health Check Properties -The `HealthCheck` class provides several configurable properties: +The `HealthCheck.Builder` class provides several configurable properties: | Property | Default | Description | |----------|---------|-------------| -| `Interval` | 5 seconds | How frequently health checks are performed | | `ProbeCount` | 3 | Number of probe operations to perform per health check | | `ProbeTimeout` | 3 seconds | Maximum time allowed for an individual probe to complete | | `ProbeInterval` | 500 milliseconds | Delay between consecutive failed probes | | `Probe` | `Ping` | The probe operation to execute | | `ProbePolicy` | `AllSuccess` | Policy for evaluating multiple probe results | +### Per-member health checks, and turning them off + +A member can use its own check - including `HealthCheck.None`, which performs no probes at all and reports `Inconclusive`, leaving that member's selection driven purely by its observed connectivity (and by its circuit-breaker): + +```csharp +ConnectionGroupMember[] members = [ + new("us-east.redis.example.com:6379", name: "US East") { Weight = 10 }, + + // a member we only want to reach for on connectivity grounds - never probe it + new("archive.redis.example.com:6379", name: "Archive") { Weight = 1, HealthCheck = HealthCheck.None }, + + // ...and one we want checked much more aggressively than the rest + new("us-west.redis.example.com:6379", name: "US West") + { + Weight = 5, + HealthCheck = new HealthCheck.Builder { ProbeCount = 5, ProbePolicy = HealthCheckProbePolicy.MajoritySuccess }, + }, +]; +``` + +To disable periodic checking for the whole group, set `MultiGroupOptions.HealthCheckInterval` to `TimeSpan.MaxValue`; the group is then only re-evaluated in response to connection events (such as a tripped circuit-breaker). + ### Built-in Probes StackExchange.Redis provides several built-in health check probes: @@ -290,7 +363,7 @@ StackExchange.Redis provides several built-in health check probes: The simplest probe that executes a `PING` command against the server: ```csharp -var healthCheck = new HealthCheck +HealthCheck healthCheck = new HealthCheck.Builder { Probe = HealthCheckProbe.Ping }; @@ -303,7 +376,7 @@ This is the default and recommended probe for most scenarios as it's lightweight Checks the connection status without sending any commands: ```csharp -var healthCheck = new HealthCheck +HealthCheck healthCheck = new HealthCheck.Builder { Probe = HealthCheckProbe.IsConnected }; @@ -316,7 +389,7 @@ This is even more lightweight than `Ping` but only verifies the socket connectio Performs a write operation to verify read/write capability: ```csharp -var healthCheck = new HealthCheck +HealthCheck healthCheck = new HealthCheck.Builder { Probe = HealthCheckProbe.StringSet }; @@ -333,7 +406,7 @@ The probe policy determines how multiple probe results are evaluated to determin The health check passes if **any** probe succeeds. This provides the most lenient evaluation: ```csharp -var healthCheck = new HealthCheck +HealthCheck healthCheck = new HealthCheck.Builder { ProbeCount = 3, ProbePolicy = HealthCheckProbePolicy.AnySuccess @@ -346,7 +419,7 @@ var healthCheck = new HealthCheck The health check passes only if **all** probes succeed. This provides the strictest evaluation: ```csharp -var healthCheck = new HealthCheck +HealthCheck healthCheck = new HealthCheck.Builder { ProbeCount = 3, ProbePolicy = HealthCheckProbePolicy.AllSuccess @@ -359,7 +432,7 @@ var healthCheck = new HealthCheck The health check passes if a **majority** of probes succeed: ```csharp -var healthCheck = new HealthCheck +HealthCheck healthCheck = new HealthCheck.Builder { ProbeCount = 3, ProbePolicy = HealthCheckProbePolicy.MajoritySuccess @@ -396,12 +469,12 @@ its state is scoped to exactly the connection whose health it is measuring; a re ### Configuring a Circuit Breaker for a Group -Circuit breakers are configured globally for all members via `MultiGroupOptions`, alongside the health check. The setting flows into every member connection: +Circuit breakers are configured for all members via `MultiGroupOptions`, alongside the health check. The setting flows into every member connection: ```csharp -var options = new MultiGroupOptions +MultiGroupOptions options = new MultiGroupOptions.Builder { - CircuitBreaker = CircuitBreaker.Default + CircuitBreaker = new CircuitBreaker.Builder { FailureRateThreshold = 25 } }; ConnectionGroupMember[] members = [ @@ -418,17 +491,19 @@ If you don't specify one, `CircuitBreaker.Default` is used automatically: // these are equivalent await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members); -var options = new MultiGroupOptions { CircuitBreaker = CircuitBreaker.Default }; +MultiGroupOptions options = new MultiGroupOptions.Builder { CircuitBreaker = CircuitBreaker.Default }; await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members, options); ``` +A circuit breaker is also useful *without* a group: set `ConfigurationOptions.CircuitBreaker` and any connection will tear itself down when its traffic starts failing. For a group member, the effective breaker is the member's `CircuitBreaker`, else the member's own `ConfigurationOptions.CircuitBreaker`, else the group default. + ### Tuning the Default Circuit Breaker The default circuit breaker uses a rolling time-window: it counts successes and failures over a short window and trips once the failure rate crosses a threshold, provided enough failures have been seen to be statistically meaningful. Use `CircuitBreaker.Builder` to tune it: ```csharp -var options = new MultiGroupOptions +MultiGroupOptions options = new MultiGroupOptions.Builder { CircuitBreaker = new CircuitBreaker.Builder { @@ -454,7 +529,7 @@ Which faults count against the breaker is decided by *classification*, not by ex Use `CircuitBreaker.None` to opt out entirely: ```csharp -var options = new MultiGroupOptions +MultiGroupOptions options = new MultiGroupOptions.Builder { CircuitBreaker = CircuitBreaker.None }; @@ -467,8 +542,9 @@ Health checks and circuit breakers keep the *group* pointed at a healthy member; ```csharp await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members); -// wrap the database once; reuse the wrapper like any other IDatabaseAsync -IDatabaseAsync db = conn.GetDatabase().WithRetry(new RetryPolicy()); +// wrap the database once; reuse the wrapper like any other IDatabaseAsync. +// the parameterless overload uses the policy configured on the connection (see below) +IDatabaseAsync db = conn.GetDatabase().WithRetry(); // a transient fault (e.g. the active member briefly returning LOADING) is retried // automatically; if the group fails over in the meantime, the retry lands on the new member @@ -479,9 +555,26 @@ var value = await db.StringGetAsync("mykey"); A retrying database can still *create* a transaction: `retryDb.CreateTransaction()` returns an `ITransactionAsync` whose `ExecuteAsync` is retried as a single unit. Each attempt replays the queued operations (and any `WATCH` constraints) against a fresh `MULTI`/`EXEC` - and, in an Active:Active group, onto whichever member is active at the time - so a transaction can ride out a failover just like a single command; the per-operation tasks handed back at build time resolve from the winning attempt. The retry-category gate (below) still applies, using the *most* side-effecting operation in the transaction: a transaction containing an `INCR` is treated as `CommandRetryWriteAccumulating`, so the default policy will not retry it unless you raise `MaxCommandRetryCategory`. +### Configuring the retry policy + +Like the health check and the circuit breaker, `RetryPolicy` can be set as a group-wide default - and the parameterless `WithRetry()` picks it up, so callers don't have to thread a policy through their code: + +```csharp +MultiGroupOptions options = new MultiGroupOptions.Builder +{ + RetryPolicy = new RetryPolicy.Builder { MaxAttempts = 5 }, +}; +await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members, options); + +// uses MultiGroupOptions.RetryPolicy +IDatabaseAsync db = conn.GetDatabase().WithRetry(); +``` + +The same works for a single connection via `ConfigurationOptions.RetryPolicy`. `WithRetry()` resolves in this order: `MultiGroupOptions.RetryPolicy` for a connection group, `ConfigurationOptions.RetryPolicy` for a single connection, else `RetryPolicy.Default`. Pass a policy explicitly - `WithRetry(policy)` - to override that for one database, and use `RetryPolicy.None` to get a wrapper that never retries. + ### RetryPolicy settings -`RetryPolicy` controls how many times, how often, and how far an operation is retried: +`RetryPolicy.Builder` controls how many times, how often, and how far an operation is retried: | Property | Default | Description | |----------|---------|-------------| @@ -493,7 +586,7 @@ A retrying database can still *create* a transaction: `retryDb.CreateTransaction | `MaxCommandRetryCategory` | `CommandRetryWriteLastWins` | The most side-effecting command category that will be retried (see below) | ```csharp -var policy = new RetryPolicy +RetryPolicy policy = new RetryPolicy.Builder { MaxAttempts = 5, RetryDelay = TimeSpan.FromMilliseconds(200), @@ -570,15 +663,24 @@ An unhealthy member is cleared in one of three ways: ### `FailbackDelay` -`MultiGroupOptions.FailbackDelay` is the interval a member must remain healthy — measured from its *most recent* failure — before it is automatically returned to rotation: +`MultiGroupOptions.FailbackDelay` is the interval a member must remain healthy - measured from its *most recent* failure - before it is automatically returned to rotation: ```csharp -var options = new MultiGroupOptions +MultiGroupOptions options = new MultiGroupOptions.Builder { FailbackDelay = TimeSpan.FromMinutes(2), // must be healthy for 2 minutes after its last failure }; ``` +Individual members can override this via `ConnectionGroupMember.FailbackDelay`, for example to hold a known-flaky region out of rotation for longer than the rest: + +```csharp +ConnectionGroupMember[] members = [ + new("us-east.redis.example.com:6379", name: "US East") { Weight = 10 }, + new("flaky.redis.example.com:6379", name: "Flaky") { Weight = 5, FailbackDelay = TimeSpan.FromMinutes(10) }, +]; +``` + | Value | Behavior | |-------|----------| | `TimeSpan.Zero` (default) | Immediate failback — the member is eligible again as soon as a health check passes | @@ -795,14 +897,16 @@ You can implement custom health check logic by extending `HealthCheckProbe`. Not if the probe involves talking to data via a `RedisKey`, as on "cluster" configurations, it must be ensured that the key used resolves to the correct server; for this purpose, the `server.InventKey` method can be used: +A probe receives a `HealthCheckContext`, carrying the `Server` being probed and the `ProbeTimeout` budget: + ```csharp public abstract class CustomProbe : HealthCheckProbe { - public override Task CheckHealthAsync(HealthCheck healthCheck, IServer server) + public override Task CheckHealthAsync(HealthCheckContext context) { // create a random key that routes to the correct server, using // the specified prefix - RedisKey key = server.InventKey("health-check/"); + RedisKey key = context.Server.InventKey("health-check/"); // ... } } @@ -814,14 +918,14 @@ Or more conveniently, the key-specific `KeyWriteHealthCheckProbe` encapsulates t public class CustomWriteProbe : KeyWriteHealthCheckProbe { public override async Task CheckHealthAsync( - HealthCheck healthCheck, + HealthCheckContext context, IDatabaseAsync database, RedisKey key) { try { var value = Guid.NewGuid().ToString(); - await database.StringSetAsync(key, value, expiry: healthCheck.ProbeTimeout); + await database.StringSetAsync(key, value, expiry: context.ProbeTimeout); bool isMatch = value == await database.StringGetAsync(key); return isMatch ? HealthCheckResult.Healthy : HealthCheckResult.Unhealthy; @@ -834,6 +938,8 @@ public class CustomWriteProbe : KeyWriteHealthCheckProbe } ``` +> The context is passed **by value** rather than by `in`, because probes are typically `async`, and async methods cannot take by-ref parameters. The sibling `HealthCheckProbePolicy.Evaluate` is synchronous, so it does take `in HealthCheckProbeContext`. + ### Custom Probe Policies In addition to the inbuilt policies, custom policies can be implemented by extending `HealthCheckProbePolicy`. @@ -859,13 +965,13 @@ public class AtLeastPolicy(int requiredSuccesses) : HealthCheckProbePolicy } // Use the custom policy requiring at least 2 successes -var healthCheck = new HealthCheck +HealthCheck healthCheck = new HealthCheck.Builder { ProbeCount = 5, // Need enough probes to allow for the required successes ProbePolicy = new AtLeastPolicy(2) }; -var options = new MultiGroupOptions +MultiGroupOptions options = new MultiGroupOptions.Builder { HealthCheck = healthCheck }; @@ -915,7 +1021,7 @@ public sealed class ConsecutiveFailureBreaker(int limit) : CircuitBreaker } } -var options = new MultiGroupOptions +MultiGroupOptions options = new MultiGroupOptions.Builder { CircuitBreaker = new ConsecutiveFailureBreaker(limit: 5) }; @@ -925,24 +1031,36 @@ Keep `ObserveResult` cheap and thread-safe: it runs on the hot path for every co ### Custom Retry Policies -`RetryPolicy` is itself extensible: override `CanRetry(in FaultContext fault)` to make the retry decision yourself. It returns a `RetryPolicyResult` — `None` to give up, or a combination of `SameServer` and `FailoverServer` to indicate where a retry may be attempted. +`RetryPolicy` is itself extensible: override `CanRetry(in FaultContext fault)` to make the retry decision yourself. It returns a `RetryResult` - `None` to give up, or a combination of `SameServer` and `FailoverServer` to indicate where a retry may be attempted. The `FaultContext` gives you the classified `ErrorKind`, the `ConnectionFailureType`, and the command `Flags` (including its retry category) to base the decision on: ```csharp public sealed class ReadOnlyOnlyRetryPolicy : RetryPolicy { - public override RetryPolicyResult CanRetry(in FaultContext fault) + public override RetryResult CanRetry(in FaultContext fault) { // only ever retry pure reads, and only on the same server if (fault.ErrorKind == RedisErrorKind.Loading && (fault.Flags & CommandFlags.CommandRetryReadOnly) != 0) { - return RetryPolicyResult.SameServer; + return RetryResult.SameServer; } // note: base.CanRetry(fault) would apply the default logic - return RetryPolicyResult.None; + return RetryResult.None; } } IDatabaseAsync db = conn.GetDatabase().WithRetry(new ReadOnlyOnlyRetryPolicy()); ``` + +A derived policy inherits the default settings; to derive *and* change the settings, take a `Builder` and pass it to the base constructor: + +```csharp +public sealed class ReadOnlyOnlyRetryPolicy(RetryPolicy.Builder builder) : RetryPolicy(builder) +{ + public override RetryResult CanRetry(in FaultContext fault) => /* ... */; +} + +IDatabaseAsync db = conn.GetDatabase().WithRetry( + new ReadOnlyOnlyRetryPolicy(new RetryPolicy.Builder { MaxAttempts = 5 })); +``` diff --git a/src/StackExchange.Redis/Availability/CircuitBreaker.cs b/src/StackExchange.Redis/Availability/CircuitBreaker.cs index 07e72d3b51..fc876898e7 100644 --- a/src/StackExchange.Redis/Availability/CircuitBreaker.cs +++ b/src/StackExchange.Redis/Availability/CircuitBreaker.cs @@ -15,10 +15,24 @@ namespace StackExchange.Redis.Availability; [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] public abstract class CircuitBreaker { + internal const double DefaultFailureRateThreshold = 10; + internal const int DefaultMinimumNumberOfFailures = 1000; + internal static readonly TimeSpan DefaultMetricsWindowSize = TimeSpan.FromSeconds(2); + + private static readonly CircuitBreaker DefaultInstance = new DefaultCircuitBreaker( +#pragma warning disable SA1114 // Parameter list should follow declaration - false positive: the #if directive splits the argument list +#if NET8_0_OR_GREATER + null, +#endif +#pragma warning restore SA1114 + DefaultFailureRateThreshold, + DefaultMinimumNumberOfFailures, + DefaultMetricsWindowSize); + /// - /// Default circuit-breaker logic. + /// Default circuit-breaker logic: trips when the failure rate over a short rolling window crosses a threshold. /// - public static CircuitBreaker Default => Builder.DefaultInstance; + public static CircuitBreaker Default => DefaultInstance; /// /// No circuit-breaker logic is applied. @@ -28,22 +42,9 @@ public abstract class CircuitBreaker /// /// Allows configuration of the default implementation. /// - public class Builder + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + public sealed class Builder { - private const double DefaultFailureRateThreshold = 10; - private const int DefaultMinimumNumberOfFailures = 1000; - private static readonly TimeSpan DefaultMetricsWindowSize = TimeSpan.FromSeconds(2); - - internal static CircuitBreaker DefaultInstance = new DefaultCircuitBreaker( -#pragma warning disable SA1114 // Parameter list should follow declaration - false positive: the #if directive splits the argument list -#if NET8_0_OR_GREATER - null, -#endif -#pragma warning restore SA1114 - DefaultFailureRateThreshold, - DefaultMinimumNumberOfFailures, - DefaultMetricsWindowSize); - /// /// Percentage of failures to trigger circuit breaker. /// @@ -73,6 +74,11 @@ public class Builder /// public CircuitBreaker Create() { + if (FailureRateThreshold is < 0 or > 100) throw new ArgumentOutOfRangeException(nameof(FailureRateThreshold), FailureRateThreshold, "A percentage between 0 and 100 is required."); + if (MinimumNumberOfFailures < 1) throw new ArgumentOutOfRangeException(nameof(MinimumNumberOfFailures), MinimumNumberOfFailures, "At least one failure is required; use CircuitBreaker.None to disable circuit-breaking."); + if (MetricsWindowSize <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(MetricsWindowSize), MetricsWindowSize, "A positive window is required."); + if (MetricsWindowSize.TotalSeconds > int.MaxValue) throw new ArgumentOutOfRangeException(nameof(MetricsWindowSize), MetricsWindowSize, "The window is too large."); + if ((FailureRateThreshold is DefaultFailureRateThreshold #if NET8_0_OR_GREATER & TimeProvider is null diff --git a/src/StackExchange.Redis/Availability/DatabaseExtensions.cs b/src/StackExchange.Redis/Availability/DatabaseExtensions.cs index 9733bf4853..6465288843 100644 --- a/src/StackExchange.Redis/Availability/DatabaseExtensions.cs +++ b/src/StackExchange.Redis/Availability/DatabaseExtensions.cs @@ -1,20 +1,36 @@ using System.Diagnostics.CodeAnalysis; using RESPite; -namespace StackExchange.Redis.Availability +namespace StackExchange.Redis.Availability; + +/// +/// Provides availability-related extension methods (such as ) to database instances. +/// +public static class DatabaseExtensions { /// - /// Provides availability-related extension methods (such as ) to database instances. + /// Automatically retry operations when connection failure occurs. This has deep integration with + /// SE.Redis concepts, so can respond to server failover events, apply circuit-breaker rules, and + /// respect command effect categorization. /// - public static class DatabaseExtensions + /// The database to wrap. + /// + /// The policy to apply; when (the default), the policy configured for the + /// underlying connection is used - for a connection group, + /// for a single connection, else + /// . + /// + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + public static IDatabaseAsync WithRetry(this IDatabaseAsync database, RetryPolicy? retryPolicy = null) + => new RetryDatabase(database, retryPolicy ?? ResolveRetryPolicy(database)); + + // IDatabaseAsync always exposes its multiplexer (via IRedisAsync), so the configured policy is reachable + // without the caller having to thread it through; note IConnectionMultiplexer is a public interface that + // callers may implement or mock, so every step here degrades to the default rather than assuming a type + private static RetryPolicy ResolveRetryPolicy(IDatabaseAsync database) => database.Multiplexer switch { - /// - /// Automatically retry operations when connection failure occurs. This has deep integration with - /// SE.Redis concepts, so can respond to server failover events, apply circuit-breaker rules, and - /// respect command effect categorization. - /// - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] - public static IDatabaseAsync WithRetry(this IDatabaseAsync database, RetryPolicy retryPolicy) - => new RetryDatabase(database, retryPolicy); - } + IConnectionGroup group => group.Options.RetryPolicy, + IInternalConnectionMultiplexer muxer => muxer.RawConfig.RetryPolicy ?? RetryPolicy.Default, + _ => RetryPolicy.Default, + }; } diff --git a/src/StackExchange.Redis/Availability/HealthCheck.ConnectedProbe.cs b/src/StackExchange.Redis/Availability/HealthCheck.ConnectedProbe.cs deleted file mode 100644 index 3850aaa61c..0000000000 --- a/src/StackExchange.Redis/Availability/HealthCheck.ConnectedProbe.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Threading.Tasks; - -namespace StackExchange.Redis.Availability; - -public sealed partial class HealthCheck -{ - public partial class HealthCheckProbe - { - /// - /// Report health using the property, without any additional tests. - /// - public static HealthCheckProbe IsConnected => ConnectedProbe.Instance; - } - - private sealed class ConnectedProbe : HealthCheckProbe - { - public static ConnectedProbe Instance { get; } = new(); - private ConnectedProbe() { } - - public override Task CheckHealthAsync(HealthCheck healthCheck, IServer server) - => server.IsConnected ? HealthyTask : UnhealthyTask; - } -} diff --git a/src/StackExchange.Redis/Availability/HealthCheck.Execute.cs b/src/StackExchange.Redis/Availability/HealthCheck.Execute.cs index 968b4e81e6..1ee5dda8a6 100644 --- a/src/StackExchange.Redis/Availability/HealthCheck.Execute.cs +++ b/src/StackExchange.Redis/Availability/HealthCheck.Execute.cs @@ -11,7 +11,9 @@ public sealed partial class HealthCheck /// Evaluate the health of the specified multiplexer, by evaluating all endpoints. /// public Task CheckHealthAsync(IConnectionMultiplexer multiplexer) - => multiplexer.IsConnected ? CheckHealthCoreAsync(multiplexer) : HealthCheckProbe.UnhealthyTask; + => !IsEnabled ? HealthCheckProbe.InconclusiveTask + : multiplexer.IsConnected ? CheckHealthCoreAsync(multiplexer) + : HealthCheckProbe.UnhealthyTask; private async Task CheckHealthCoreAsync(IConnectionMultiplexer multiplexer) { @@ -51,21 +53,32 @@ private async Task CheckHealthCoreAsync(IConnectionMultiplexe internal int TotalTimeoutMillis() { - int count = ProbeCount; - if (count <= 0) - { - Debug.Fail("We shouldn't get as far as calculating timeouts with a non-positive probe count."); - return 0; - } + bool valid = TryComputeTotalTimeoutMillis(ProbeCount, ProbeTimeout, ProbeInterval, out int millis); + Debug.Assert(valid, "The probe budget is validated by HealthCheck.Builder.Create, so should always be usable here."); + return millis; + } - TimeSpan probeTimeout = ProbeTimeout, probeInterval = ProbeInterval; + // the total time budget for a full health check, in milliseconds; shared with Builder.Create, which uses + // it to reject a configuration whose budget cannot be expressed (rather than overflowing at check time) + private static bool TryComputeTotalTimeoutMillis(int probeCount, TimeSpan probeTimeout, TimeSpan probeInterval, out int millis) + { + millis = 0; + if (probeCount < 1 || probeTimeout <= TimeSpan.Zero || probeInterval < TimeSpan.Zero) return false; - // the first probe doesn't have an interval before it, the rest do - var totalTicks = probeTimeout.Ticks - + ((probeTimeout.Ticks + probeInterval.Ticks) * (count - 1)); - var millis = (int)TimeSpan.FromTicks(totalTicks).TotalMilliseconds; - Debug.Assert(millis > 0, "Total timeout should be positive"); - return millis; + try + { + // the first probe doesn't have an interval before it, the rest do + long totalTicks = checked(probeTimeout.Ticks + ((probeTimeout.Ticks + probeInterval.Ticks) * (probeCount - 1))); + long totalMillis = totalTicks / TimeSpan.TicksPerMillisecond; + if (totalMillis is <= 0 or > int.MaxValue) return false; + + millis = (int)totalMillis; + return true; + } + catch (OverflowException) + { + return false; + } } // apply timeout and collation logic to a group of probes @@ -130,19 +143,22 @@ internal static void PutReusablePending(ref Task[]? field, re /// Evaluate the health of an endpoint. /// public Task CheckHealthAsync(IServer server) - => server.IsConnected ? CheckHealthCoreAsync(server) : HealthCheckProbe.UnhealthyTask; + => !IsEnabled ? HealthCheckProbe.InconclusiveTask + : server.IsConnected ? CheckHealthCoreAsync(server) + : HealthCheckProbe.UnhealthyTask; private async Task CheckHealthCoreAsync(IServer server) { try { int timeout = (int)ProbeTimeout.TotalMilliseconds, success = 0, failure = 0, remaining = ProbeCount; + HealthCheckContext context = new(server, ProbeTimeout); while (remaining > 0) { HealthCheckResult probeResult; try { - var pendingProbe = Probe.CheckHealthAsync(this, server); + var pendingProbe = Probe.CheckHealthAsync(context); probeResult = await pendingProbe.TimeoutAfter(timeout).ForAwait() ? await pendingProbe.ForAwait() // completed : HealthCheckResult.Unhealthy; // timeout diff --git a/src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbe.cs b/src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbe.cs deleted file mode 100644 index 6789bc8910..0000000000 --- a/src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbe.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Threading.Tasks; -using RESPite; - -namespace StackExchange.Redis.Availability; - -public sealed partial class HealthCheck -{ - /// - /// Describes an operation to perform as part of a health check. - /// - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] - public abstract partial class HealthCheckProbe - { - /// - /// Check the health of the specified endpoint. - /// - public abstract Task CheckHealthAsync(HealthCheck healthCheck, IServer server); - - private static Task? _inconclusive, _healthy, _unhealthy; - - /// - /// Reports a memoized probe that was skipped without being evaluated. - /// - protected internal static Task InconclusiveTask => _inconclusive ??= Task.FromResult(HealthCheckResult.Inconclusive); - - /// - /// Reports a memoized probe that was healthy. - /// - protected internal static Task HealthyTask => _healthy ??= Task.FromResult(HealthCheckResult.Healthy); - - /// - /// Reports a memoized probe that was unhealthy. - /// - protected internal static Task UnhealthyTask => _unhealthy ??= Task.FromResult(HealthCheckResult.Unhealthy); - } - - /// - /// Describes a key-based (write) operation to perform as part of a health check. - /// - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] - public abstract class KeyWriteHealthCheckProbe : HealthCheckProbe - { - /// - public override Task CheckHealthAsync(HealthCheck healthCheck, IServer server) - { - if (server.IsReplica) return InconclusiveTask; - - RedisKey key = server.InventKey("health-check/"); - if (key.IsNull) return InconclusiveTask; - Debug.Assert(server.Multiplexer.GetServer(key).EndPoint == server.EndPoint, "Key was not routed to the correct endpoint"); - return CheckHealthAsync(healthCheck, server.Multiplexer.GetDatabase(), key); - } - - /// - /// Check the health of the specified database using the provided key. - /// - public abstract Task CheckHealthAsync(HealthCheck healthCheck, IDatabaseAsync database, RedisKey key); - } - - /// - /// Indicates the result of a health check. - /// - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] - public enum HealthCheckResult - { - /// - /// The health check was skipped or could not be determined. - /// - Inconclusive, - - /// - /// The health check was successful. - /// - Healthy, - - /// - /// The health check failed. - /// - Unhealthy, - } -} diff --git a/src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbeContext.cs b/src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbeContext.cs deleted file mode 100644 index 82062e830c..0000000000 --- a/src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbeContext.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using RESPite; - -namespace StackExchange.Redis.Availability; - -public sealed partial class HealthCheck -{ - /// - /// Represents the context of a health check probe. - /// - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] - public readonly struct HealthCheckProbeContext(HealthCheckResult result, int success, int failure, int remaining, TimeSpan probeInterval) - { - /// - public override string ToString() => $"Result: {Result}, Success: {Success}, Failure: {Failure}, Remaining: {Remaining}, ProbeInterval: {ProbeInterval}"; - - /// - /// Gets the most recent result. - /// - public HealthCheckResult Result => result; - - /// - /// Gets the number of successful health checks. - /// - public int Success => success; - - /// - /// Gets the number of failed health checks. - /// - public int Failure => failure; - - /// - /// Gets the number of remaining health checks. - /// - public int Remaining => remaining; - - /// - /// Gets the interval to wait before the next probe attempt. - /// - public TimeSpan ProbeInterval => probeInterval; - } -} diff --git a/src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbePolicy.cs b/src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbePolicy.cs deleted file mode 100644 index 5e465dae2b..0000000000 --- a/src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbePolicy.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using RESPite; - -namespace StackExchange.Redis.Availability; - -public sealed partial class HealthCheck -{ - /// - /// Attempt to evaluate the outcome of a series of health check operations. - /// - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] - public abstract class HealthCheckProbePolicy - { - /// - /// Attempt to evaluate the policy given the current context. - /// - /// The state of the probes so far. - /// The result of the policy evaluation. - public abstract HealthCheckResult Evaluate(in HealthCheckProbeContext context); - - /// - /// Get the interval to wait before the next probe attempt. - /// - internal TimeSpan GetEffectiveProbeInterval(in HealthCheckProbeContext context) - { - // if we make this public / overrideable, we will need to think about the max delay timeout - - // only delay between failures - return context.Result is HealthCheckResult.Unhealthy ? context.ProbeInterval : TimeSpan.Zero; - } - - /// - /// Require all probes to succeed. - /// - public static HealthCheckProbePolicy AllSuccess => AllSuccessHealthCheckProbePolicy.Instance; - - /// - /// Require at least one probe to succeed. - /// - public static HealthCheckProbePolicy AnySuccess => AnySuccessHealthCheckProbePolicy.Instance; - - /// - /// Require a majority of probes to succeed. - /// - public static HealthCheckProbePolicy MajoritySuccess => MajoritySuccessHealthCheckProbePolicy.Instance; - - private sealed class AllSuccessHealthCheckProbePolicy : HealthCheckProbePolicy - { - public static readonly AllSuccessHealthCheckProbePolicy Instance = new(); - private AllSuccessHealthCheckProbePolicy() { } - - public override HealthCheckResult Evaluate(in HealthCheckProbeContext context) - { - // Fail as soon as we have any failure - if (context.Failure > 0) return HealthCheckResult.Unhealthy; - - // Succeed only when all probes have succeeded (no remaining) - if (context.Remaining == 0) return HealthCheckResult.Healthy; - - // Can't determine yet - return HealthCheckResult.Inconclusive; - } - } - - private sealed class AnySuccessHealthCheckProbePolicy : HealthCheckProbePolicy - { - public static readonly AnySuccessHealthCheckProbePolicy Instance = new(); - private AnySuccessHealthCheckProbePolicy() { } - - public override HealthCheckResult Evaluate(in HealthCheckProbeContext context) - { - // Succeed as soon as we have any success - if (context.Success > 0) return HealthCheckResult.Healthy; - - // Fail only when all probes have failed (no remaining) - if (context.Remaining == 0) return HealthCheckResult.Unhealthy; - - // Can't determine yet - return HealthCheckResult.Inconclusive; - } - } - - private sealed class MajoritySuccessHealthCheckProbePolicy : HealthCheckProbePolicy - { - public static readonly MajoritySuccessHealthCheckProbePolicy Instance = new(); - private MajoritySuccessHealthCheckProbePolicy() { } - - public override HealthCheckResult Evaluate(in HealthCheckProbeContext context) - { - int total = context.Success + context.Failure + context.Remaining; - int majority = (total / 2) + 1; - - // Succeed as soon as we have enough successes for a majority - if (context.Success >= majority) return HealthCheckResult.Healthy; - - // Fail as soon as we have enough failures to make a majority impossible - if (context.Failure >= majority) return HealthCheckResult.Unhealthy; - - // Can't determine yet - return HealthCheckResult.Inconclusive; - } - } - } -} diff --git a/src/StackExchange.Redis/Availability/HealthCheck.cs b/src/StackExchange.Redis/Availability/HealthCheck.cs index 8345ed01b5..3419ad85df 100644 --- a/src/StackExchange.Redis/Availability/HealthCheck.cs +++ b/src/StackExchange.Redis/Availability/HealthCheck.cs @@ -1,7 +1,5 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using System.Threading; using RESPite; namespace StackExchange.Redis.Availability; @@ -9,117 +7,180 @@ namespace StackExchange.Redis.Availability; /// /// Describes a health check to perform against instances. /// +/// +/// Instances are immutable and safe to share between members; use to configure +/// a custom check. Note that how often checks run is a group-level concern, configured via +/// , not a property of the check itself. +/// [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] -public sealed partial class HealthCheck : ICloneable +public sealed partial class HealthCheck { - private static HealthCheck? _default; + internal const int DefaultProbeCount = 3; + internal static readonly TimeSpan DefaultProbeTimeout = TimeSpan.FromSeconds(3); + internal static readonly TimeSpan DefaultProbeInterval = TimeSpan.FromMilliseconds(500); + + private static readonly HealthCheck DefaultInstance = new( + enabled: true, + DefaultProbeCount, + DefaultProbeTimeout, + DefaultProbeInterval, + HealthCheckProbe.Ping, + HealthCheckProbePolicy.AllSuccess); + + private static readonly HealthCheck DisabledInstance = new( + enabled: false, + DefaultProbeCount, + DefaultProbeTimeout, + DefaultProbeInterval, + HealthCheckProbe.None, + HealthCheckProbePolicy.AllSuccess); /// - /// The default health check options. These options are immutable and cannot be modified; to customize, either - /// use to create a mutable copy, or create a new instance - and customize as needed. + /// The default health check: three probes, all of which must succeed. /// - public static HealthCheck Default => _default ?? CreateDefault(); - - private static HealthCheck CreateDefault() - { - var options = new HealthCheck(); - options.Freeze(); - // memoize, preferring to re-use the existing instance if we're competing (but since frozen: that's fine) - return Interlocked.CompareExchange(ref _default, options, null) ?? options; - } - - internal void Freeze() => _frozen = true; - private bool _frozen; + public static HealthCheck Default => DefaultInstance; /// - /// Create a mutable copy of this health check. + /// No health check is performed; every check reports , leaving + /// member selection driven purely by the observed connectivity of each member (and by any circuit-breaker). /// - public HealthCheck Clone() => new() + public static HealthCheck None => DisabledInstance; + + private HealthCheck( + bool enabled, + int probeCount, + TimeSpan probeTimeout, + TimeSpan probeInterval, + HealthCheckProbe probe, + HealthCheckProbePolicy probePolicy) { - // note: do not copy _frozen - Interval = Interval, - ProbeCount = ProbeCount, - ProbeTimeout = ProbeTimeout, - ProbeInterval = ProbeInterval, - Probe = Probe, - ProbePolicy = ProbePolicy, - }; + IsEnabled = enabled; + ProbeCount = probeCount; + ProbeTimeout = probeTimeout; + ProbeInterval = probeInterval; + Probe = probe; + ProbePolicy = probePolicy; + } - object ICloneable.Clone() => Clone(); + /// + public override string ToString() => IsEnabled + ? $"{Probe.GetType().Name} x{ProbeCount} ({ProbePolicy.GetType().Name})" + : "(disabled)"; /// - /// Create a new health check instance. + /// Whether this health check performs any probes; false only for . /// - public HealthCheck() - { - Interval = TimeSpan.FromSeconds(5); - ProbeCount = 3; - ProbeTimeout = TimeSpan.FromSeconds(3); - ProbeInterval = TimeSpan.FromMilliseconds(500); - Probe = HealthCheckProbe.Ping; - ProbePolicy = HealthCheckProbePolicy.AllSuccess; - } + public bool IsEnabled { get; } /// - /// Gets or sets the interval at which health checks should be performed. + /// Gets the number of probes to perform for this health check. /// - public TimeSpan Interval - { - get; - set => SetField(ref field, value); - } + public int ProbeCount { get; } /// - /// Gets or sets the number of probes to perform for this health check. + /// Gets the time that should be allowed for an individual probe to complete. /// - public int ProbeCount - { - get; - set => SetField(ref field, value); - } + public TimeSpan ProbeTimeout { get; } /// - /// Gets or sets the time that should be allowed for an individual probe to complete. + /// Gets the interval between failed probes. /// - public TimeSpan ProbeTimeout - { - get; - set => SetField(ref field, value); - } + public TimeSpan ProbeInterval { get; } /// - /// Gets or sets the interval between failed probes. + /// Gets the probe to use for this health check. /// - public TimeSpan ProbeInterval - { - get; - set => SetField(ref field, value); - } + public HealthCheckProbe Probe { get; } /// - /// Gets or sets the probe to use for this health check. + /// Gets the policy to use for this health check. /// - public HealthCheckProbe Probe - { - get; - set => SetField(ref field, value); - } + public HealthCheckProbePolicy ProbePolicy { get; } /// - /// Gets or sets the policy to use for this health check. + /// Allows configuration of a . /// - public HealthCheckProbePolicy ProbePolicy + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + public sealed class Builder { - get; - set => SetField(ref field, value); - } - - // ReSharper disable once RedundantAssignment - private void SetField(ref T field, T value, [CallerMemberName] string caller = "") - { - if (_frozen) Throw(caller); - field = value; - - static void Throw(string caller) => throw new InvalidOperationException($"{nameof(HealthCheck)}.{caller} cannot be modified once the object is in use."); + /// + /// Create a builder pre-populated with the default values. + /// + public Builder() + { + } + + /// + /// Create a builder pre-populated from an existing . + /// + public Builder(HealthCheck healthCheck) + { + ProbeCount = healthCheck.ProbeCount; + ProbeTimeout = healthCheck.ProbeTimeout; + ProbeInterval = healthCheck.ProbeInterval; + Probe = healthCheck.Probe; + ProbePolicy = healthCheck.ProbePolicy; + } + + /// + /// The number of probes to perform for this health check. + /// + public int ProbeCount { get; set; } = DefaultProbeCount; + + /// + /// The time that should be allowed for an individual probe to complete. + /// + public TimeSpan ProbeTimeout { get; set; } = DefaultProbeTimeout; + + /// + /// The interval between failed probes. + /// + public TimeSpan ProbeInterval { get; set; } = DefaultProbeInterval; + + /// + /// The probe to use for this health check. + /// + public HealthCheckProbe Probe { get; set; } = HealthCheckProbe.Ping; + + /// + /// The policy to use for this health check. + /// + public HealthCheckProbePolicy ProbePolicy { get; set; } = HealthCheckProbePolicy.AllSuccess; + + /// + /// Create a new health check instance. + /// + public HealthCheck Create() + { + if (ProbeCount < 1) throw new ArgumentOutOfRangeException(nameof(ProbeCount), ProbeCount, "At least one probe is required; use HealthCheck.None to disable health checks."); + if (ProbeTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(ProbeTimeout), ProbeTimeout, "A positive probe timeout is required."); + if (ProbeInterval < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(ProbeInterval), ProbeInterval, "A non-negative probe interval is required."); + if (Probe is null) throw new ArgumentNullException(nameof(Probe)); + if (ProbePolicy is null) throw new ArgumentNullException(nameof(ProbePolicy)); + + // the total budget is expressed in int milliseconds when the check runs; validate that here, + // rather than letting it overflow into a nonsensical (or negative) timeout later + if (!TryComputeTotalTimeoutMillis(ProbeCount, ProbeTimeout, ProbeInterval, out _)) + { + throw new ArgumentOutOfRangeException(nameof(ProbeTimeout), "The combined probe budget (ProbeCount, ProbeTimeout, ProbeInterval) is too large."); + } + + // prefer the shared default instance when nothing has been customized + if (ProbeCount == DefaultProbeCount + && ProbeTimeout == DefaultProbeTimeout + && ProbeInterval == DefaultProbeInterval + && ReferenceEquals(Probe, HealthCheckProbe.Ping) + && ReferenceEquals(ProbePolicy, HealthCheckProbePolicy.AllSuccess)) + { + return DefaultInstance; + } + + return new HealthCheck(enabled: true, ProbeCount, ProbeTimeout, ProbeInterval, Probe, ProbePolicy); + } + + /// + /// Create a new health check instance. + /// + public static implicit operator HealthCheck(Builder builder) => builder.Create(); } } diff --git a/src/StackExchange.Redis/Availability/HealthCheckContext.cs b/src/StackExchange.Redis/Availability/HealthCheckContext.cs new file mode 100644 index 0000000000..8f7a7a13b5 --- /dev/null +++ b/src/StackExchange.Redis/Availability/HealthCheckContext.cs @@ -0,0 +1,30 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using RESPite; + +namespace StackExchange.Redis.Availability; + +/// +/// Describes the target of a single health-check probe, and the budget available to it. +/// +/// +/// This is passed by value rather than by in, because probe implementations are +/// typically async, and async methods cannot take by-ref parameters. +/// +[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +public readonly struct HealthCheckContext(IServer server, TimeSpan probeTimeout) +{ + /// + public override string ToString() => $"{Server?.EndPoint} (timeout: {ProbeTimeout})"; + + /// + /// Gets the server being probed. + /// + public IServer Server => server; + + /// + /// Gets the time allowed for this probe to complete; probes are not required to enforce this + /// themselves (the caller applies it), but may use it to bound any state they create. + /// + public TimeSpan ProbeTimeout => probeTimeout; +} diff --git a/src/StackExchange.Redis/Availability/HealthCheckProbe.Connected.cs b/src/StackExchange.Redis/Availability/HealthCheckProbe.Connected.cs new file mode 100644 index 0000000000..6d058f3a39 --- /dev/null +++ b/src/StackExchange.Redis/Availability/HealthCheckProbe.Connected.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; + +namespace StackExchange.Redis.Availability; + +public abstract partial class HealthCheckProbe +{ + /// + /// Report health using the property, without any additional tests. + /// + public static HealthCheckProbe IsConnected => ConnectedProbe.Instance; + + private sealed class ConnectedProbe : HealthCheckProbe + { + public static ConnectedProbe Instance { get; } = new(); + private ConnectedProbe() { } + + public override Task CheckHealthAsync(HealthCheckContext context) + => context.Server.IsConnected ? HealthyTask : UnhealthyTask; + } +} diff --git a/src/StackExchange.Redis/Availability/HealthCheckProbe.None.cs b/src/StackExchange.Redis/Availability/HealthCheckProbe.None.cs new file mode 100644 index 0000000000..c114193e95 --- /dev/null +++ b/src/StackExchange.Redis/Availability/HealthCheckProbe.None.cs @@ -0,0 +1,21 @@ +using System.Threading.Tasks; + +namespace StackExchange.Redis.Availability; + +public abstract partial class HealthCheckProbe +{ + /// + /// Performs no test at all, always reporting ; this is the + /// probe used by , and leaves member selection driven purely by the + /// observed connectivity of each member. + /// + public static HealthCheckProbe None => NoneProbe.Instance; + + private sealed class NoneProbe : HealthCheckProbe + { + public static NoneProbe Instance { get; } = new(); + private NoneProbe() { } + + public override Task CheckHealthAsync(HealthCheckContext context) => InconclusiveTask; + } +} diff --git a/src/StackExchange.Redis/Availability/HealthCheck.PingProbe.cs b/src/StackExchange.Redis/Availability/HealthCheckProbe.Ping.cs similarity index 50% rename from src/StackExchange.Redis/Availability/HealthCheck.PingProbe.cs rename to src/StackExchange.Redis/Availability/HealthCheckProbe.Ping.cs index 594a1bbebd..64b1e882d8 100644 --- a/src/StackExchange.Redis/Availability/HealthCheck.PingProbe.cs +++ b/src/StackExchange.Redis/Availability/HealthCheckProbe.Ping.cs @@ -2,24 +2,21 @@ namespace StackExchange.Redis.Availability; -public sealed partial class HealthCheck +public abstract partial class HealthCheckProbe { - public partial class HealthCheckProbe - { - /// - /// Verify that the server is responsive by sending a PING command. - /// - public static HealthCheckProbe Ping => PingProbe.Instance; - } + /// + /// Verify that the server is responsive by sending a PING command. + /// + public static HealthCheckProbe Ping => PingProbe.Instance; private sealed class PingProbe : HealthCheckProbe { public static PingProbe Instance { get; } = new(); private PingProbe() { } - public override async Task CheckHealthAsync(HealthCheck healthCheck, IServer server) + public override async Task CheckHealthAsync(HealthCheckContext context) { - await server.PingAsync(); + await context.Server.PingAsync(); return HealthCheckResult.Healthy; } } diff --git a/src/StackExchange.Redis/Availability/HealthCheck.StringSetProbe.cs b/src/StackExchange.Redis/Availability/HealthCheckProbe.StringSet.cs similarity index 79% rename from src/StackExchange.Redis/Availability/HealthCheck.StringSetProbe.cs rename to src/StackExchange.Redis/Availability/HealthCheckProbe.StringSet.cs index 1a6bd2d2d9..6e1524b7a6 100644 --- a/src/StackExchange.Redis/Availability/HealthCheck.StringSetProbe.cs +++ b/src/StackExchange.Redis/Availability/HealthCheckProbe.StringSet.cs @@ -4,15 +4,12 @@ namespace StackExchange.Redis.Availability; -public sealed partial class HealthCheck +public abstract partial class HealthCheckProbe { - public partial class HealthCheckProbe - { - /// - /// Verify that a string can be successfully set and retrieved. - /// - public static HealthCheckProbe StringSet => StringSetProbe.Instance; - } + /// + /// Verify that a string can be successfully set and retrieved. + /// + public static HealthCheckProbe StringSet => StringSetProbe.Instance; internal sealed class StringSetProbe : KeyWriteHealthCheckProbe { @@ -23,7 +20,7 @@ private StringSetProbe() { } private static Random SharedRandom { get; } = new(); #endif - public override async Task CheckHealthAsync(HealthCheck healthCheck, IDatabaseAsync database, RedisKey key) + public override async Task CheckHealthAsync(HealthCheckContext context, IDatabaseAsync database, RedisKey key) { // note we use the lock API here because that can selectively choose between appropriate strategies for // different server versions, including DELEX @@ -41,7 +38,7 @@ public override async Task CheckHealthAsync(HealthCheck healt await database.LockTakeAsync( key: key, value: payload, - expiry: healthCheck.ProbeTimeout, + expiry: context.ProbeTimeout, flags: CommandFlags.FireAndForget).ForAwait(); // release from the db if matches (otherwise, we have no clue what happened, so: leave alone) diff --git a/src/StackExchange.Redis/Availability/HealthCheckProbe.cs b/src/StackExchange.Redis/Availability/HealthCheckProbe.cs new file mode 100644 index 0000000000..56a6dc9a66 --- /dev/null +++ b/src/StackExchange.Redis/Availability/HealthCheckProbe.cs @@ -0,0 +1,59 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using RESPite; + +namespace StackExchange.Redis.Availability; + +/// +/// Describes an operation to perform as part of a health check. +/// +[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +public abstract partial class HealthCheckProbe +{ + /// + /// Check the health of the specified endpoint. + /// + public abstract Task CheckHealthAsync(HealthCheckContext context); + + private static Task? _inconclusive, _healthy, _unhealthy; + + /// + /// Reports a memoized probe that was skipped without being evaluated. + /// + protected internal static Task InconclusiveTask => _inconclusive ??= Task.FromResult(HealthCheckResult.Inconclusive); + + /// + /// Reports a memoized probe that was healthy. + /// + protected internal static Task HealthyTask => _healthy ??= Task.FromResult(HealthCheckResult.Healthy); + + /// + /// Reports a memoized probe that was unhealthy. + /// + protected internal static Task UnhealthyTask => _unhealthy ??= Task.FromResult(HealthCheckResult.Unhealthy); +} + +/// +/// Describes a key-based (write) operation to perform as part of a health check. +/// +[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +public abstract class KeyWriteHealthCheckProbe : HealthCheckProbe +{ + /// + public sealed override Task CheckHealthAsync(HealthCheckContext context) + { + var server = context.Server; + if (server.IsReplica) return InconclusiveTask; + + RedisKey key = server.InventKey("health-check/"); + if (key.IsNull) return InconclusiveTask; + Debug.Assert(server.Multiplexer.GetServer(key).EndPoint == server.EndPoint, "Key was not routed to the correct endpoint"); + return CheckHealthAsync(context, server.Multiplexer.GetDatabase(), key); + } + + /// + /// Check the health of the specified database using the provided key. + /// + public abstract Task CheckHealthAsync(HealthCheckContext context, IDatabaseAsync database, RedisKey key); +} diff --git a/src/StackExchange.Redis/Availability/HealthCheckProbeContext.cs b/src/StackExchange.Redis/Availability/HealthCheckProbeContext.cs new file mode 100644 index 0000000000..3feb5d8b98 --- /dev/null +++ b/src/StackExchange.Redis/Availability/HealthCheckProbeContext.cs @@ -0,0 +1,40 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using RESPite; + +namespace StackExchange.Redis.Availability; + +/// +/// Represents the context of a health check probe. +/// +[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +public readonly struct HealthCheckProbeContext(HealthCheckResult result, int success, int failure, int remaining, TimeSpan probeInterval) +{ + /// + public override string ToString() => $"Result: {Result}, Success: {Success}, Failure: {Failure}, Remaining: {Remaining}, ProbeInterval: {ProbeInterval}"; + + /// + /// Gets the most recent result. + /// + public HealthCheckResult Result => result; + + /// + /// Gets the number of successful health checks. + /// + public int Success => success; + + /// + /// Gets the number of failed health checks. + /// + public int Failure => failure; + + /// + /// Gets the number of remaining health checks. + /// + public int Remaining => remaining; + + /// + /// Gets the interval to wait before the next probe attempt. + /// + public TimeSpan ProbeInterval => probeInterval; +} diff --git a/src/StackExchange.Redis/Availability/HealthCheckProbePolicy.cs b/src/StackExchange.Redis/Availability/HealthCheckProbePolicy.cs new file mode 100644 index 0000000000..4eb6954757 --- /dev/null +++ b/src/StackExchange.Redis/Availability/HealthCheckProbePolicy.cs @@ -0,0 +1,102 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using RESPite; + +namespace StackExchange.Redis.Availability; + +/// +/// Attempt to evaluate the outcome of a series of health check operations. +/// +[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +public abstract class HealthCheckProbePolicy +{ + /// + /// Attempt to evaluate the policy given the current context. + /// + /// The state of the probes so far. + /// The result of the policy evaluation. + public abstract HealthCheckResult Evaluate(in HealthCheckProbeContext context); + + /// + /// Get the interval to wait before the next probe attempt. + /// + internal TimeSpan GetEffectiveProbeInterval(in HealthCheckProbeContext context) + { + // if we make this public / overrideable, we will need to think about the max delay timeout + + // only delay between failures + return context.Result is HealthCheckResult.Unhealthy ? context.ProbeInterval : TimeSpan.Zero; + } + + /// + /// Require all probes to succeed. + /// + public static HealthCheckProbePolicy AllSuccess => AllSuccessHealthCheckProbePolicy.Instance; + + /// + /// Require at least one probe to succeed. + /// + public static HealthCheckProbePolicy AnySuccess => AnySuccessHealthCheckProbePolicy.Instance; + + /// + /// Require a majority of probes to succeed. + /// + public static HealthCheckProbePolicy MajoritySuccess => MajoritySuccessHealthCheckProbePolicy.Instance; + + private sealed class AllSuccessHealthCheckProbePolicy : HealthCheckProbePolicy + { + public static readonly AllSuccessHealthCheckProbePolicy Instance = new(); + private AllSuccessHealthCheckProbePolicy() { } + + public override HealthCheckResult Evaluate(in HealthCheckProbeContext context) + { + // Fail as soon as we have any failure + if (context.Failure > 0) return HealthCheckResult.Unhealthy; + + // Succeed only when all probes have succeeded (no remaining) + if (context.Remaining == 0) return HealthCheckResult.Healthy; + + // Can't determine yet + return HealthCheckResult.Inconclusive; + } + } + + private sealed class AnySuccessHealthCheckProbePolicy : HealthCheckProbePolicy + { + public static readonly AnySuccessHealthCheckProbePolicy Instance = new(); + private AnySuccessHealthCheckProbePolicy() { } + + public override HealthCheckResult Evaluate(in HealthCheckProbeContext context) + { + // Succeed as soon as we have any success + if (context.Success > 0) return HealthCheckResult.Healthy; + + // Fail only when all probes have failed (no remaining) + if (context.Remaining == 0) return HealthCheckResult.Unhealthy; + + // Can't determine yet + return HealthCheckResult.Inconclusive; + } + } + + private sealed class MajoritySuccessHealthCheckProbePolicy : HealthCheckProbePolicy + { + public static readonly MajoritySuccessHealthCheckProbePolicy Instance = new(); + private MajoritySuccessHealthCheckProbePolicy() { } + + public override HealthCheckResult Evaluate(in HealthCheckProbeContext context) + { + int total = context.Success + context.Failure + context.Remaining; + int majority = (total / 2) + 1; + + // Succeed as soon as we have enough successes for a majority + if (context.Success >= majority) return HealthCheckResult.Healthy; + + // Fail as soon as we have enough failures to make a majority impossible + if (context.Failure >= majority) return HealthCheckResult.Unhealthy; + + // Can't determine yet + return HealthCheckResult.Inconclusive; + } + } +} diff --git a/src/StackExchange.Redis/Availability/HealthCheckResult.cs b/src/StackExchange.Redis/Availability/HealthCheckResult.cs new file mode 100644 index 0000000000..c8632507c1 --- /dev/null +++ b/src/StackExchange.Redis/Availability/HealthCheckResult.cs @@ -0,0 +1,26 @@ +using System.Diagnostics.CodeAnalysis; +using RESPite; + +namespace StackExchange.Redis.Availability; + +/// +/// Indicates the result of a health check. +/// +[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +public enum HealthCheckResult +{ + /// + /// The health check was skipped or could not be determined. + /// + Inconclusive, + + /// + /// The health check was successful. + /// + Healthy, + + /// + /// The health check failed. + /// + Unhealthy, +} diff --git a/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs index 4ff4eb8720..9505d55214 100644 --- a/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs @@ -33,9 +33,7 @@ public static Task ConnectGroupAsync( { // create a defensive copy of the array; we don't want callers being able to radically swap things! members = (ConnectionGroupMember[])members.Clone(); - options ??= MultiGroupOptions.Default; - options.Freeze(); - return MultiGroupMultiplexer.ConnectAsync(members, options, log); + return MultiGroupMultiplexer.ConnectAsync(members, options ?? MultiGroupOptions.Default, log); } /// @@ -55,9 +53,7 @@ public static Task ConnectGroupAsync( TextWriter? log = null) #pragma warning restore RS0026 { - options ??= MultiGroupOptions.Default; - options.Freeze(); - return MultiGroupMultiplexer.ConnectAsync([member0, member1], options, log); + return MultiGroupMultiplexer.ConnectAsync([member0, member1], options ?? MultiGroupOptions.Default, log); } } @@ -206,6 +202,42 @@ public bool SkipInitialHealthCheck /// public string Name { get; private set; } = name; + // ---- per-member overrides of the group-wide MultiGroupOptions defaults ---- + // every value on MultiGroupOptions that can vary per member appears here as a nullable + // counterpart; null means "use the group default". These are read when the member is added + // to a group (for the circuit-breaker, which is fixed at connection construction) or on each + // health-check pass (for the rest), so changing them later is only meaningful for the latter. + + /// + /// The health-check to use for this member; when , + /// is used. Use + /// to leave this member's selection driven purely by its observed connectivity. + /// + public HealthCheck? HealthCheck { get; set; } + + /// + /// The circuit-breaker to use for this member; when , the member's own + /// is used, else + /// . This is applied when the member connects, so + /// setting it after the member has been added to a group has no effect on existing connections. + /// + public CircuitBreaker? CircuitBreaker { get; set; } + + /// + /// How long this member must remain healthy, following its most recent failure, before it is + /// eligible to be selected again; when , + /// is used. + /// + public TimeSpan? FailbackDelay { get; set; } + + // the breaker to hand to this member's connections: member override, else its own config, else the group + internal CircuitBreaker? ResolveCircuitBreaker(MultiGroupOptions options) + => CircuitBreaker ?? Configuration.CircuitBreaker ?? options.CircuitBreaker; + + internal HealthCheck ResolveHealthCheck(MultiGroupOptions options) => HealthCheck ?? options.HealthCheck; + + internal TimeSpan ResolveFailbackDelay(MultiGroupOptions options) => FailbackDelay ?? options.FailbackDelay; + /// /// The relative weight of this group member; higher is preferred. /// @@ -282,12 +314,12 @@ internal static uint ToLatencyTicks(TimeSpan latency) return x; } - internal GroupConnectionChangedEventArgs.ChangeType UpdateState(HealthCheck.HealthCheckResult result, long failbackFailureCutoffTicks) + internal GroupConnectionChangedEventArgs.ChangeType UpdateState(HealthCheckResult result, long failbackFailureCutoffTicks) { bool isConnected; if (_muxer is { IsConnected: true } muxer) { - isConnected = result is not HealthCheck.HealthCheckResult.Unhealthy; + isConnected = result is not HealthCheckResult.Unhealthy; SetLatency(muxer.UpdateLatency()); } else @@ -444,8 +476,10 @@ internal static async Task ConnectAsync( var config = members[i].Configuration; config.AbortOnConnectFail = false; config.HeartbeatConsistencyChecks = true; - config.CircuitBreaker ??= options.CircuitBreaker; // AA options flow into the children - pending[i] = ConnectionMultiplexer.ConnectAsync(config, log); + + // note the resolved circuit-breaker is passed *alongside* the configuration rather than + // written into it; see ConnectionMultiplexer.GroupCircuitBreaker + pending[i] = ConnectionMultiplexer.ConnectGroupMemberAsync(config, log, members[i].ResolveCircuitBreaker(options)); } for (int i = 0; i < pending.Length; i++) @@ -463,6 +497,8 @@ internal static async Task ConnectAsync( private readonly MultiGroupOptions _options; + public MultiGroupOptions Options => _options; + private MultiGroupMultiplexer(ConnectionGroupMember[] members, MultiGroupOptions options) { _options = options; @@ -538,10 +574,11 @@ static async Task PollAsync(WeakReference weakRef, CancellationToken cancellatio static bool TryGetHealthCheck(object? target, out TimeSpan interval) { - if (target is MultiGroupMultiplexer typed - && typed._options.HealthCheck is { } healthCheck) + if (target is MultiGroupMultiplexer typed) { - interval = healthCheck.Interval; + // note the interval is a group-level concern (how often we re-evaluate the active + // member), not a property of any individual health-check + interval = typed._options.HealthCheckInterval; return interval > TimeSpan.Zero & interval != TimeSpan.MaxValue; } @@ -552,28 +589,32 @@ static bool TryGetHealthCheck(object? target, out TimeSpan interval) internal bool IsDisposed => _disposed; - private Task[]? _reusableHealthCheckBuffer; + private Task[]? _reusableHealthCheckBuffer; private int _healthCheckGate; // 0 = idle, 1 = a check/select pass is in flight (see TryHealthCheckAndSelectPreferredGroupAsync) internal async Task RunHealthCheckAsync() { if (_disposed) return; - var healthCheck = _options.HealthCheck; var members = _members; + if (members.Length == 0) return; // nothing to check (and no budget to compute) + var pending = HealthCheck.GetReusablePending(ref _reusableHealthCheckBuffer, members.Length); + + // members can use different health-checks (see ConnectionGroupMember.HealthCheck), so the + // budget for the whole pass is the largest individual budget + int totalTimeoutMillis = 0; for (int i = 0; i < members.Length; i++) { - // per-member health-check overrides the group default when specified (see the group/muxer - // split on circuit-breakers); left null, we fall back to the shared group health-check var muxer = members[i].Multiplexer; - pending[i] = (muxer.RawConfig.HealthCheck ?? healthCheck).CheckHealthAsync(muxer); + var healthCheck = members[i].ResolveHealthCheck(_options); + totalTimeoutMillis = Math.Max(totalTimeoutMillis, healthCheck.TotalTimeoutMillis()); + pending[i] = healthCheck.CheckHealthAsync(muxer); } - await Task.WhenAll(pending).TimeoutAfter(healthCheck.TotalTimeoutMillis()).ForAwait(); - var failbackFailureCutoff = GetFailbackFailureCutoff(); + await Task.WhenAll(pending).TimeoutAfter(totalTimeoutMillis).ForAwait(); for (int i = 0; i < pending.Length; i++) { - HealthCheck.HealthCheckResult result; + HealthCheckResult result; if (pending[i].IsCompletedSuccessfully) { result = await pending[i].ForAwait(); @@ -581,10 +622,10 @@ internal async Task RunHealthCheckAsync() else { _ = pending[i].ObserveErrors(); - result = HealthCheck.HealthCheckResult.Unhealthy; + result = HealthCheckResult.Unhealthy; } - var delta = members[i].UpdateState(result, failbackFailureCutoff); + var delta = members[i].UpdateState(result, GetFailbackFailureCutoff(members[i])); if (delta != GroupConnectionChangedEventArgs.ChangeType.Unknown) { OnConnectionChanged(delta, members[i]); @@ -594,13 +635,13 @@ internal async Task RunHealthCheckAsync() HealthCheck.PutReusablePending(ref _reusableHealthCheckBuffer, ref pending); } - private long GetFailbackFailureCutoff() + private long GetFailbackFailureCutoff(ConnectionGroupMember member) { // the minimum last-observed unhealthy time (as UTC ticks) that we'll allow for reconnect; // for example, if the FailbackDelay is 2 minutes, and the time is 14:32:55, then the last // failure must have happened at 14:30:55 or earlier. Pure long tick math on the wall clock: // DateTime.Ticks and TimeSpan.Ticks are the same 100ns unit, so the subtraction is valid - var delay = _options.FailbackDelay; + var delay = member.ResolveFailbackDelay(_options); if (delay == TimeSpan.MaxValue) return long.MinValue; // manual mode: never auto-reset return DateTime.UtcNow.Ticks - delay.Ticks; @@ -1241,8 +1282,8 @@ public async Task AddAsync(ConnectionGroupMember member, TextWriter? log = null) member.Init(_members.Length); member.Configuration.AbortOnConnectFail = false; // members are gated by health-checks, not connect-fail member.Configuration.HeartbeatConsistencyChecks = true; - member.Configuration.CircuitBreaker ??= _options.CircuitBreaker; // AA options flow into the children - var muxer = await ConnectionMultiplexer.ConnectAsync(member.Configuration, log).ConfigureAwait(false); + var muxer = await ConnectionMultiplexer.ConnectGroupMemberAsync( + member.Configuration, log, member.ResolveCircuitBreaker(_options)).ConfigureAwait(false); member.SetMultiplexer(muxer); // unless told otherwise, run an initial health-check so a healthy member can be selected immediately; @@ -1250,9 +1291,8 @@ public async Task AddAsync(ConnectionGroupMember member, TextWriter? log = null) // passes - this is the only way to add a member that is not yet healthy if (!member.SkipInitialHealthCheck) { - var health = await (muxer.RawConfig.HealthCheck ?? _options.HealthCheck) - .CheckHealthAsync(muxer).ConfigureAwait(false); - member.UpdateState(health, GetFailbackFailureCutoff()); + var health = await member.ResolveHealthCheck(_options).CheckHealthAsync(muxer).ConfigureAwait(false); + member.UpdateState(health, GetFailbackFailureCutoff(member)); } // apply any shared hooks diff --git a/src/StackExchange.Redis/Availability/MultiGroupOptions.cs b/src/StackExchange.Redis/Availability/MultiGroupOptions.cs index 779a1b6fd4..f6260cb4fc 100644 --- a/src/StackExchange.Redis/Availability/MultiGroupOptions.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupOptions.cs @@ -1,7 +1,5 @@ -using System; +using System; using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using System.Threading; using RESPite; namespace StackExchange.Redis.Availability; @@ -9,41 +7,64 @@ namespace StackExchange.Redis.Availability; /// /// Configuration options for controlling connections to multiple groups. /// +/// +/// Instances are immutable; use to configure. Every value here is a group-wide +/// default, and can be overridden per-member by the matching property on +/// (where one exists); the effective value is "member override, else group default". +/// [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] -public sealed class MultiGroupOptions() +public sealed class MultiGroupOptions { - private static MultiGroupOptions? _default; - private bool _frozen; + internal static readonly TimeSpan DefaultHealthCheckInterval = TimeSpan.FromSeconds(5); + internal static readonly TimeSpan DefaultFailbackDelay = TimeSpan.Zero; + + private static readonly MultiGroupOptions DefaultInstance = new( + HealthCheck.Default, CircuitBreaker.Default, RetryPolicy.Default, DefaultHealthCheckInterval, DefaultFailbackDelay); /// /// Default shared options. /// - public static MultiGroupOptions Default => _default ??= CreateDefault(); + public static MultiGroupOptions Default => DefaultInstance; - private static MultiGroupOptions CreateDefault() + private MultiGroupOptions( + HealthCheck healthCheck, + CircuitBreaker circuitBreaker, + RetryPolicy retryPolicy, + TimeSpan healthCheckInterval, + TimeSpan failbackDelay) { - var options = new MultiGroupOptions(); - options.Freeze(); - return Interlocked.CompareExchange(ref _default, options, null) ?? options; + HealthCheck = healthCheck; + CircuitBreaker = circuitBreaker; + RetryPolicy = retryPolicy; + HealthCheckInterval = healthCheckInterval; + FailbackDelay = failbackDelay; } + /// + public override string ToString() => $"health-check: {HealthCheck} every {HealthCheckInterval}; failback: {FailbackDelay}"; + /// /// The health check to use for members of the group when no per-member health check is specified. /// - public HealthCheck HealthCheck - { - get => field ?? HealthCheck.Default; - set => SetField(ref field, value); - } + public HealthCheck HealthCheck { get; } /// /// The circuit-breaker to use for members of the group when no per-member circuit-breaker is specified. /// - public CircuitBreaker CircuitBreaker - { - get => field ?? CircuitBreaker.Default; - set => SetField(ref field, value); - } + public CircuitBreaker CircuitBreaker { get; } + + /// + /// The retry policy used by for databases + /// obtained from this group. + /// + public RetryPolicy RetryPolicy { get; } + + /// + /// How frequently health checks are performed, and therefore how frequently the active member is + /// re-evaluated. disables periodic checking entirely (the group is then + /// only re-evaluated in response to connection events such as a tripped circuit-breaker). + /// + public TimeSpan HealthCheckInterval { get; } /// /// If a member has been marked unhealthy by a failing health-check or circuit-breaker, it will not be @@ -52,20 +73,88 @@ public CircuitBreaker CircuitBreaker /// , failback is not automatic and requires explicit use of /// or . /// - public TimeSpan FailbackDelay - { - get => field; - set => SetField(ref field, value); - } + public TimeSpan FailbackDelay { get; } - // ReSharper disable once RedundantAssignment - private void SetField(ref T field, T value, [CallerMemberName] string caller = "") + /// + /// Allows configuration of . + /// + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + public sealed class Builder { - if (_frozen) Throw(caller); - field = value; + /// + /// Create a builder pre-populated with the default values. + /// + public Builder() + { + } - static void Throw(string caller) => throw new InvalidOperationException($"{nameof(MultiGroupOptions)}.{caller} cannot be modified once the object is in use."); - } + /// + /// Create a builder pre-populated from existing options. + /// + public Builder(MultiGroupOptions options) + { + HealthCheck = options.HealthCheck; + CircuitBreaker = options.CircuitBreaker; + RetryPolicy = options.RetryPolicy; + HealthCheckInterval = options.HealthCheckInterval; + FailbackDelay = options.FailbackDelay; + } + + /// + /// The health check to use for members of the group when no per-member health check is specified. + /// + public HealthCheck HealthCheck { get; set; } = HealthCheck.Default; + + /// + /// The circuit-breaker to use for members of the group when no per-member circuit-breaker is specified. + /// + public CircuitBreaker CircuitBreaker { get; set; } = CircuitBreaker.Default; + + /// + /// The retry policy used by for databases + /// obtained from this group. + /// + public RetryPolicy RetryPolicy { get; set; } = RetryPolicy.Default; - internal void Freeze() => _frozen = true; + /// + /// How frequently health checks are performed, and therefore how frequently the active member is + /// re-evaluated; disables periodic checking. + /// + public TimeSpan HealthCheckInterval { get; set; } = DefaultHealthCheckInterval; + + /// + /// How long a member must remain healthy, following its most recent failure, before it is eligible to + /// be selected again; requires explicit intervention. + /// + public TimeSpan FailbackDelay { get; set; } = DefaultFailbackDelay; + + /// + /// Create a new options instance. + /// + public MultiGroupOptions Create() + { + if (HealthCheck is null) throw new ArgumentNullException(nameof(HealthCheck)); + if (CircuitBreaker is null) throw new ArgumentNullException(nameof(CircuitBreaker)); + if (RetryPolicy is null) throw new ArgumentNullException(nameof(RetryPolicy)); + if (HealthCheckInterval <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(HealthCheckInterval), HealthCheckInterval, "A positive interval is required; use TimeSpan.MaxValue to disable periodic health checks."); + if (FailbackDelay < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(FailbackDelay), FailbackDelay, "A non-negative delay is required."); + + // prefer the shared default instance when nothing has been customized + if (ReferenceEquals(HealthCheck, HealthCheck.Default) + && ReferenceEquals(CircuitBreaker, CircuitBreaker.Default) + && ReferenceEquals(RetryPolicy, RetryPolicy.Default) + && HealthCheckInterval == DefaultHealthCheckInterval + && FailbackDelay == DefaultFailbackDelay) + { + return DefaultInstance; + } + + return new MultiGroupOptions(HealthCheck, CircuitBreaker, RetryPolicy, HealthCheckInterval, FailbackDelay); + } + + /// + /// Create a new options instance. + /// + public static implicit operator MultiGroupOptions(Builder builder) => builder.Create(); + } } diff --git a/src/StackExchange.Redis/Availability/RetryController.cs b/src/StackExchange.Redis/Availability/RetryController.cs index a4b9fea6ac..72bbf8c335 100644 --- a/src/StackExchange.Redis/Availability/RetryController.cs +++ b/src/StackExchange.Redis/Availability/RetryController.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using StackExchange.Redis.Interfaces; @@ -19,22 +20,27 @@ public RetryController(RetryPolicy policy, DatabaseFeatureFlags features) { _policy = policy; - // capture config locally rather than constant cross-object lookups (plus: mutability) + // capture config locally rather than constant cross-object lookups; a RetryPolicy is immutable and + // is validated by RetryPolicy.Builder, so no range checks are needed here _maxBeforeFailover = (features & DatabaseFeatureFlags.Failover) == 0 ? int.MaxValue : policy.MaxAttemptsBeforeFailover; _maxAttempts = policy.MaxAttempts; if (_maxBeforeFailover == _maxAttempts) _maxBeforeFailover = int.MaxValue; // then we'll never look - // guard the failover threshold: values < 1 can never be hit by the loop counter (which starts at 1), - // so they would *silently* disable failover rather than erroring; validate the raw policy value - if (policy.MaxAttemptsBeforeFailover < 1) throw new ArgumentOutOfRangeException(nameof(policy.MaxAttemptsBeforeFailover)); - _delayMillis = policy.DelayMilliseconds; - _failoverMillis = policy.FailoverMilliseconds; - _jitterMillis = policy.JitterMilliseconds; - if (_delayMillis < 0) throw new ArgumentOutOfRangeException(nameof(policy.RetryDelay)); - if (_jitterMillis < 0) throw new ArgumentOutOfRangeException(nameof(policy.JitterMax)); - if (_failoverMillis < 0) throw new ArgumentOutOfRangeException(nameof(policy.FailoverDelay)); + _delayMillis = ToMilliseconds(policy.RetryDelay); + _failoverMillis = ToMilliseconds(policy.FailoverDelay); + _jitterMillis = ToMilliseconds(policy.JitterMax); + + Debug.Assert(_maxAttempts >= 1 && _maxBeforeFailover >= 1, "attempt counts should be validated by RetryPolicy"); + Debug.Assert(_delayMillis >= 0 && _jitterMillis >= 0 && _failoverMillis >= 0, "delays should be validated by RetryPolicy"); + + static int ToMilliseconds(TimeSpan value) => (int)(value.Ticks / TimeSpan.TicksPerMillisecond); } + /// + /// The policy this controller is applying; exposed for tests, which assert how a policy was resolved. + /// + public RetryPolicy Policy => _policy; + /// /// Whether it is ever worth capturing the next-failover token: only when there is more than one /// attempt and the failover threshold sits below the attempt cap. @@ -57,14 +63,14 @@ public bool CanRetry( // ask the retry policy for advice, and mask off the bits we know about FaultContext ctx = new(fault); var policy = _policy.CanRetry(ctx) & - (RetryPolicy.RetryPolicyResult.FailoverServer | RetryPolicy.RetryPolicyResult.SameServer); + (RetryResult.FailoverServer | RetryResult.SameServer); if (policy is 0) { // retry policy says: nope return false; } - if (policy is RetryPolicy.RetryPolicyResult.FailoverServer) + if (policy is RetryResult.FailoverServer) { // we can *only* retry on a different server; is failover available? delay = failover; @@ -77,11 +83,11 @@ public bool CanRetry( // by count, we should really switch over to the failover now; is failover available *and* are we allowed? delay = failover; failover = CancellationToken.None; // only failover once - return delay.CanBeCanceled & (policy & RetryPolicy.RetryPolicyResult.FailoverServer) != 0; + return delay.CanBeCanceled & (policy & RetryResult.FailoverServer) != 0; } // can we pause and retry on the same server? - return (policy & RetryPolicy.RetryPolicyResult.SameServer) != 0; + return (policy & RetryResult.SameServer) != 0; } public Task FailoverOrDelayAsync(CancellationToken delay) diff --git a/src/StackExchange.Redis/Availability/RetryDatabase.cs b/src/StackExchange.Redis/Availability/RetryDatabase.cs index dbb6064ddf..958299db2c 100644 --- a/src/StackExchange.Redis/Availability/RetryDatabase.cs +++ b/src/StackExchange.Redis/Availability/RetryDatabase.cs @@ -22,6 +22,8 @@ DatabaseFeatureFlags IInternalDatabaseAsync.GetFeatures(out string name) private readonly IDatabaseAsync _inner; private readonly RetryController _controller; + internal RetryPolicy Policy => _controller.Policy; + public CancellationToken GetNextFailover() => _controller.TracksFailover ? _inner.GetNextFailover() : CancellationToken.None; diff --git a/src/StackExchange.Redis/Availability/RetryPolicy.cs b/src/StackExchange.Redis/Availability/RetryPolicy.cs index 447424b2fc..ffc2a3142e 100644 --- a/src/StackExchange.Redis/Availability/RetryPolicy.cs +++ b/src/StackExchange.Redis/Availability/RetryPolicy.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using RESPite; @@ -9,78 +8,116 @@ namespace StackExchange.Redis.Availability; /// Configures how messages can be retried due to connection / transient faults. Other faults (such as invalid /// usage) are not retried. /// +/// +/// Instances are immutable and safe to share; use to configure the standard policy, or +/// derive from this type and override to make the decision yourself. +/// [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] public class RetryPolicy { + internal const int DefaultMaxAttempts = 3; + internal const int DefaultMaxAttemptsBeforeFailover = 1; + internal const CommandFlags DefaultMaxCommandRetryCategory = CommandFlags.CommandRetryWriteLastWins; + internal static readonly TimeSpan DefaultRetryDelay = TimeSpan.FromSeconds(1); + internal static readonly TimeSpan DefaultJitterMax = TimeSpan.FromMilliseconds(500); + internal static readonly TimeSpan DefaultFailoverDelay = TimeSpan.FromSeconds(5); + + private static readonly RetryPolicy DefaultInstance = new(); + + /// + /// The default retry policy; retries transient faults up to times, for commands + /// at or below . + /// + public static RetryPolicy Default => DefaultInstance; + + /// + /// Never retries anything; useful to disable retries without restructuring calling code. + /// + public static RetryPolicy None => NoRetryPolicy.Instance; + + /// + /// Create a policy using the default settings; intended for use by derived types that override + /// - use to obtain the standard policy. + /// + protected RetryPolicy() + : this(DefaultMaxAttempts, DefaultMaxAttemptsBeforeFailover, DefaultRetryDelay, DefaultJitterMax, DefaultFailoverDelay, DefaultMaxCommandRetryCategory) + { + } + + /// + /// Create a policy using the settings from the supplied ; intended for use by + /// derived types that override . + /// + protected RetryPolicy(Builder builder) + : this( + Validate(builder).MaxAttempts, + builder.MaxAttemptsBeforeFailover, + builder.RetryDelay, + builder.JitterMax, + builder.FailoverDelay, + builder.MaxCommandRetryCategory) + { + } + + private RetryPolicy( + int maxAttempts, + int maxAttemptsBeforeFailover, + TimeSpan retryDelay, + TimeSpan jitterMax, + TimeSpan failoverDelay, + CommandFlags maxCommandRetryCategory) + { + MaxAttempts = maxAttempts; + MaxAttemptsBeforeFailover = maxAttemptsBeforeFailover; + RetryDelay = retryDelay; + JitterMax = jitterMax; + FailoverDelay = failoverDelay; + MaxCommandRetryCategory = maxCommandRetryCategory; + } + + /// + public override string ToString() => $"{GetType().Name}: {MaxAttempts} attempt(s), up to {MaxCommandRetryCategory}"; + /// /// The maximum number of times an operation can be attempted. Defaults to 3. /// - public int MaxAttempts { get; set; } = 3; + public int MaxAttempts { get; } /// /// The maximum number of times to retry an operation before waiting for failover; this only currently /// applies to multi-group connections created via ConnectionMultiplexer.ConnectGroupAsync. /// Defaults to 1. /// - public int MaxAttemptsBeforeFailover { get; set; } = 1; - - private int _delayMillis = 1000, _jitterMillis = 500, _failoverMillis = 5000; + public int MaxAttemptsBeforeFailover { get; } /// /// Gets the time to wait between retries that are *not* dependent on a failover happening. Defaults to 1 second. /// - public TimeSpan RetryDelay - { - get => TimeSpan.FromMilliseconds(_delayMillis); - set => _delayMillis = checked((int)value.TotalMilliseconds); - } + public TimeSpan RetryDelay { get; } /// /// Gets the time to wait for a failover, after attempts. Only one /// failover attempt is awaited. When this applies, is ignored, /// but is still respected. Defaults to 5 seconds. /// - public TimeSpan FailoverDelay - { - get => TimeSpan.FromMilliseconds(_failoverMillis); - set => _failoverMillis = checked((int)value.TotalMilliseconds); - } + public TimeSpan FailoverDelay { get; } /// - /// Gets or sets the upper bound for jitter - additional random delay between retries to prevent stampedes. + /// Gets the upper bound for jitter - additional random delay between retries to prevent stampedes. /// Defaults to 0.5 seconds, meaning between 0 and 0.5 *additional* seconds on top of . /// - public TimeSpan JitterMax - { - get => TimeSpan.FromMilliseconds(_jitterMillis); - set => _jitterMillis = checked((int)value.TotalMilliseconds); - } - - internal int DelayMilliseconds => _delayMillis; - internal int JitterMilliseconds => _jitterMillis; - internal int FailoverMilliseconds => _failoverMillis; + public TimeSpan JitterMax { get; } /// - /// Gets or sets the max side-effect category that will be retried; defaults to . + /// Gets the max side-effect category that will be retried; defaults to . /// - public CommandFlags MaxCommandRetryCategory - { - get => _maxCommandRetryCategory; - set - { - if ((value & Message.MaskRetryCategory) is 0 | (value & ~Message.MaskRetryCategory) is not 0) - throw new InvalidOperationException("Valid CommandRetry* flags should be specified"); - _maxCommandRetryCategory = value; - } - } - - private CommandFlags _maxCommandRetryCategory = CommandFlags.CommandRetryWriteLastWins; + public CommandFlags MaxCommandRetryCategory { get; } /// /// Controls which operations can be repeated, optionally indicating that this should progress to /// a new server. /// - public virtual RetryPolicyResult CanRetry(in FaultContext fault) + public virtual RetryResult CanRetry(in FaultContext fault) { var actual = fault.Flags & Message.MaskRetryCategory; if (actual is 0) actual = CommandFlags.CommandRetryWriteAccumulating; // if not set, assume similar to INCR @@ -88,47 +125,144 @@ public virtual RetryPolicyResult CanRetry(in FaultContext fault) if (actual is CommandFlags.CommandRetryNever) { // explicitly disabled at command level - return RetryPolicyResult.None; + return RetryResult.None; } if (actual > MaxCommandRetryCategory) // note this also covers CommandRetryAlways { // side-effects are beyond what the policy allows - return RetryPolicyResult.None; + return RetryResult.None; } if (CircuitBreaker.DefaultIsFailure(in fault)) { // assume we can send it everywhere - var result = RetryPolicyResult.SameServer | RetryPolicyResult.FailoverServer; + var result = RetryResult.SameServer | RetryResult.FailoverServer; if ((fault.Flags & Message.CommandServerSpecific) != 0) - result &= ~RetryPolicyResult.FailoverServer; + result &= ~RetryResult.FailoverServer; return result; } // do not retry - return RetryPolicyResult.None; + return RetryResult.None; + } + + private static Builder Validate(Builder builder) + { + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (builder.MaxAttempts < 1) throw new ArgumentOutOfRangeException(nameof(builder.MaxAttempts), builder.MaxAttempts, "At least one attempt is required; use RetryPolicy.None to disable retries."); + + // values < 1 can never be hit by the attempt counter (which starts at 1), so they would *silently* + // disable failover rather than erroring + if (builder.MaxAttemptsBeforeFailover < 1) throw new ArgumentOutOfRangeException(nameof(builder.MaxAttemptsBeforeFailover), builder.MaxAttemptsBeforeFailover, "At least one attempt is required before failover."); + if (builder.RetryDelay < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(builder.RetryDelay), builder.RetryDelay, "A non-negative retry delay is required."); + if (builder.JitterMax < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(builder.JitterMax), builder.JitterMax, "A non-negative jitter bound is required."); + if (builder.FailoverDelay < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(builder.FailoverDelay), builder.FailoverDelay, "A non-negative failover delay is required."); + + // the retry loop expresses all three delays in int milliseconds + if (!IsExpressibleAsMilliseconds(builder.RetryDelay)) throw new ArgumentOutOfRangeException(nameof(builder.RetryDelay), builder.RetryDelay, "The retry delay is too large."); + if (!IsExpressibleAsMilliseconds(builder.JitterMax)) throw new ArgumentOutOfRangeException(nameof(builder.JitterMax), builder.JitterMax, "The jitter bound is too large."); + if (!IsExpressibleAsMilliseconds(builder.FailoverDelay)) throw new ArgumentOutOfRangeException(nameof(builder.FailoverDelay), builder.FailoverDelay, "The failover delay is too large."); + + var category = builder.MaxCommandRetryCategory; + if ((category & Message.MaskRetryCategory) is 0 | (category & ~Message.MaskRetryCategory) is not 0) + { + throw new ArgumentException("A single valid CommandRetry* flag should be specified.", nameof(builder.MaxCommandRetryCategory)); + } + + return builder; + + static bool IsExpressibleAsMilliseconds(TimeSpan value) => value.Ticks / TimeSpan.TicksPerMillisecond <= int.MaxValue; } /// - /// Indicates the result of a query. + /// Allows configuration of the standard implementation. /// - [Flags] - public enum RetryPolicyResult + [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + public sealed class Builder { /// - /// None; the operation should not be retried. + /// Create a builder pre-populated with the default values. + /// + public Builder() + { + } + + /// + /// Create a builder pre-populated from an existing . + /// + public Builder(RetryPolicy policy) + { + MaxAttempts = policy.MaxAttempts; + MaxAttemptsBeforeFailover = policy.MaxAttemptsBeforeFailover; + RetryDelay = policy.RetryDelay; + JitterMax = policy.JitterMax; + FailoverDelay = policy.FailoverDelay; + MaxCommandRetryCategory = policy.MaxCommandRetryCategory; + } + + /// + /// The maximum number of times an operation can be attempted. /// - None = 0, + public int MaxAttempts { get; set; } = DefaultMaxAttempts; /// - /// The operation can be retried on the same server. + /// The maximum number of times to retry an operation before waiting for failover. /// - SameServer = 1, + public int MaxAttemptsBeforeFailover { get; set; } = DefaultMaxAttemptsBeforeFailover; /// - /// The operation can be retried on a different server after a failover operation. + /// The time to wait between retries that are *not* dependent on a failover happening. /// - FailoverServer = 2, + public TimeSpan RetryDelay { get; set; } = DefaultRetryDelay; + + /// + /// The upper bound for jitter - additional random delay between retries to prevent stampedes. + /// + public TimeSpan JitterMax { get; set; } = DefaultJitterMax; + + /// + /// The time to wait for a failover, after attempts. + /// + public TimeSpan FailoverDelay { get; set; } = DefaultFailoverDelay; + + /// + /// The max side-effect category that will be retried. + /// + public CommandFlags MaxCommandRetryCategory { get; set; } = DefaultMaxCommandRetryCategory; + + /// + /// Create a new retry policy instance. + /// + public RetryPolicy Create() + { + Validate(this); + + // prefer the shared default instance when nothing has been customized + if (MaxAttempts == DefaultMaxAttempts + && MaxAttemptsBeforeFailover == DefaultMaxAttemptsBeforeFailover + && RetryDelay == DefaultRetryDelay + && JitterMax == DefaultJitterMax + && FailoverDelay == DefaultFailoverDelay + && MaxCommandRetryCategory == DefaultMaxCommandRetryCategory) + { + return DefaultInstance; + } + + return new RetryPolicy(MaxAttempts, MaxAttemptsBeforeFailover, RetryDelay, JitterMax, FailoverDelay, MaxCommandRetryCategory); + } + + /// + /// Create a new retry policy instance. + /// + public static implicit operator RetryPolicy(Builder builder) => builder.Create(); + } + + private sealed class NoRetryPolicy : RetryPolicy + { + public static readonly NoRetryPolicy Instance = new(); + private NoRetryPolicy() { } + + public override RetryResult CanRetry(in FaultContext fault) => RetryResult.None; } } diff --git a/src/StackExchange.Redis/Availability/RetryResult.cs b/src/StackExchange.Redis/Availability/RetryResult.cs new file mode 100644 index 0000000000..404955212d --- /dev/null +++ b/src/StackExchange.Redis/Availability/RetryResult.cs @@ -0,0 +1,28 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using RESPite; + +namespace StackExchange.Redis.Availability; + +/// +/// Indicates the result of a query. +/// +[Flags] +[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +public enum RetryResult +{ + /// + /// None; the operation should not be retried. + /// + None = 0, + + /// + /// The operation can be retried on the same server. + /// + SameServer = 1, + + /// + /// The operation can be retried on a different server after a failover operation. + /// + FailoverServer = 2, +} diff --git a/src/StackExchange.Redis/ConfigurationOptions.cs b/src/StackExchange.Redis/ConfigurationOptions.cs index ee6cd6f32d..90e90933e1 100644 --- a/src/StackExchange.Redis/ConfigurationOptions.cs +++ b/src/StackExchange.Redis/ConfigurationOptions.cs @@ -980,7 +980,7 @@ public static ConfigurationOptions Parse(string configuration, bool ignoreUnknow heartbeatInterval = heartbeatInterval, WriteMode = WriteMode, CircuitBreaker = CircuitBreaker, - HealthCheck = HealthCheck, + RetryPolicy = RetryPolicy, #if DEBUG OutputLog = OutputLog, #endif @@ -1185,7 +1185,7 @@ private void Clear() _protocol = default; WriteMode = default; CircuitBreaker = null; - HealthCheck = null; + RetryPolicy = null; #if DEBUG OutputLog = null; #endif @@ -1386,18 +1386,25 @@ public RedisProtocol? Protocol internal BufferedStreamWriter.WriteMode WriteMode { get; set; } /// - /// The circuit-breaker to apply to physical connections; when null, no breaker is used. When - /// connecting a group, this flows in from if not set explicitly. + /// The circuit-breaker to apply to physical connections; when null, no breaker is used. /// + /// + /// For a member of a connection group, the effective breaker is + /// , else this, else + /// . + /// [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] public CircuitBreaker? CircuitBreaker { get; set; } /// - /// The health-check to apply when this connection is a member of a group; when null, the - /// group-level is used. + /// The retry policy used by for databases + /// obtained from this connection; when null, is used. /// + /// + /// For a member of a connection group, applies instead. + /// [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] - public HealthCheck? HealthCheck { get; set; } + public RetryPolicy? RetryPolicy { get; set; } internal bool AllowSimulateConnectionFailure { diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index 86fd7783d0..44b2957e68 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -47,6 +47,21 @@ public sealed partial class ConnectionMultiplexer : IInternalConnectionMultiplex internal CommandMap CommandMap { get; } internal EndPointCollection EndPoints { get; } internal ConfigurationOptions RawConfig { get; } + + /// + /// When this multiplexer is a member of a connection group, the group resolves the effective + /// circuit-breaker (member override, else this member's own configuration, else the group default) + /// and supplies it here. This deliberately does *not* write back into : callers + /// may legitimately reuse a single across multiple connections, + /// and a group default must not leak into an unrelated one. + /// + internal Availability.CircuitBreaker? GroupCircuitBreaker { get; private set; } + + /// + /// The circuit-breaker that physical connections for this multiplexer should use, if any. + /// + internal Availability.CircuitBreaker? EffectiveCircuitBreaker => GroupCircuitBreaker ?? RawConfig.CircuitBreaker; + internal ServerSelectionStrategy ServerSelectionStrategy { get; } ServerSelectionStrategy IInternalConnectionMultiplexer.ServerSelectionStrategy => ServerSelectionStrategy; ConnectionMultiplexer IInternalConnectionMultiplexer.UnderlyingMultiplexer => this; @@ -125,10 +140,11 @@ static ConnectionMultiplexer() SetAutodetectFeatureFlags(); } - private ConnectionMultiplexer(ConfigurationOptions configuration, ServerType? serverType = null, EndPointCollection? endpoints = null) + private ConnectionMultiplexer(ConfigurationOptions configuration, ServerType? serverType = null, EndPointCollection? endpoints = null, Availability.CircuitBreaker? groupCircuitBreaker = null) { Interlocked.Increment(ref s_MuxerCreateCount); + GroupCircuitBreaker = groupCircuitBreaker; RawConfig = configuration ?? throw new ArgumentNullException(nameof(configuration)); EndPoints = endpoints ?? RawConfig.EndPoints.Clone(); EndPoints.SetDefaultPorts(serverType, ssl: RawConfig.Ssl); @@ -156,9 +172,9 @@ private ConnectionMultiplexer(ConfigurationOptions configuration, ServerType? se lastHeartbeatTicks = Environment.TickCount; } - private static ConnectionMultiplexer CreateMultiplexer(ConfigurationOptions configuration, ILogger? log, ServerType? serverType, out EventHandler? connectHandler, EndPointCollection? endpoints = null) + private static ConnectionMultiplexer CreateMultiplexer(ConfigurationOptions configuration, ILogger? log, ServerType? serverType, out EventHandler? connectHandler, EndPointCollection? endpoints = null, Availability.CircuitBreaker? groupCircuitBreaker = null) { - var muxer = new ConnectionMultiplexer(configuration, serverType, endpoints); + var muxer = new ConnectionMultiplexer(configuration, serverType, endpoints, groupCircuitBreaker); connectHandler = null; if (log is not null) { @@ -575,7 +591,35 @@ public static Task ConnectAsync(ConfigurationOptions conf : ConnectImplAsync(configuration, log); } - private static async Task ConnectImplAsync(ConfigurationOptions configuration, TextWriter? writer = null, ServerType? serverType = null) + /// + /// Connect a multiplexer that is a member of a connection group, applying the group's resolved + /// circuit-breaker without writing it back into the caller's + /// (which the caller may legitimately reuse for other connections). + /// + internal static Task ConnectGroupMemberAsync(ConfigurationOptions configuration, TextWriter? log, Availability.CircuitBreaker? groupCircuitBreaker) + { + Dependencies.Assert(); + Validate(configuration); + + if (configuration.IsSentinel) + { + // the sentinel path builds the primary connection internally, so we cannot pass the breaker + // down into construction; apply it afterwards - it is picked up by subsequent physical + // connections, and an explicit ConfigurationOptions.CircuitBreaker still applies throughout + return ApplyAfterConnectAsync(SentinelPrimaryConnectAsync(configuration, log), groupCircuitBreaker); + } + + return ConnectImplAsync(configuration, log, groupCircuitBreaker: groupCircuitBreaker); + + static async Task ApplyAfterConnectAsync(Task pending, Availability.CircuitBreaker? groupCircuitBreaker) + { + var muxer = await pending.ForAwait(); + muxer.GroupCircuitBreaker = groupCircuitBreaker; + return muxer; + } + } + + private static async Task ConnectImplAsync(ConfigurationOptions configuration, TextWriter? writer = null, ServerType? serverType = null, Availability.CircuitBreaker? groupCircuitBreaker = null) { IDisposable? killMe = null; EventHandler? connectHandler = null; @@ -587,7 +631,7 @@ private static async Task ConnectImplAsync(ConfigurationO var sw = ValueStopwatch.StartNew(); log?.LogInformationConnectingAsync(RuntimeInformation.FrameworkDescription, Utils.GetLibVersion()); - muxer = CreateMultiplexer(configuration, log, serverType, out connectHandler); + muxer = CreateMultiplexer(configuration, log, serverType, out connectHandler, groupCircuitBreaker: groupCircuitBreaker); killMe = muxer; Interlocked.Increment(ref muxer._connectAttemptCount); bool configured = await muxer.ReconfigureAsync(first: true, reconfigureAll: false, log, null, "connect").ObserveErrors().ForAwait(); diff --git a/src/StackExchange.Redis/Interfaces/IConnectionGroup.cs b/src/StackExchange.Redis/Interfaces/IConnectionGroup.cs index e15f951afd..1d401d7421 100644 --- a/src/StackExchange.Redis/Interfaces/IConnectionGroup.cs +++ b/src/StackExchange.Redis/Interfaces/IConnectionGroup.cs @@ -50,6 +50,12 @@ public interface IConnectionGroup : IConnectionMultiplexer /// Gets the currently active member. /// ConnectionGroupMember? ActiveMember { get; } + + /// + /// Gets the group-wide options this group was created with; these are immutable, and are the defaults + /// against which each 's overrides are resolved. + /// + MultiGroupOptions Options { get; } } /// diff --git a/src/StackExchange.Redis/PhysicalConnection.cs b/src/StackExchange.Redis/PhysicalConnection.cs index bbf4b68114..3bfb8faba3 100644 --- a/src/StackExchange.Redis/PhysicalConnection.cs +++ b/src/StackExchange.Redis/PhysicalConnection.cs @@ -118,8 +118,9 @@ public PhysicalConnection(PhysicalBridge bridge, BufferedStreamWriter.WriteMode _inputCancel = new(); _outputCancel = new(); } - // grab a per-connection accumulator from the configured breaker (null when none is configured) - circuitBreaker = bridge.Multiplexer.RawConfig.CircuitBreaker?.CreateAccumulator(); + // grab a per-connection accumulator from the configured breaker (null when none is configured); + // for a connection-group member this resolves the group's default too - see EffectiveCircuitBreaker + circuitBreaker = bridge.Multiplexer.EffectiveCircuitBreaker?.CreateAccumulator(); OnCreateEcho(); } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 174c7f37a7..1e48387d6d 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable StackExchange.Redis.IDatabase.ListMove(StackExchange.Redis.RedisKey sourceKey, StackExchange.Redis.RedisKey destinationKey, StackExchange.Redis.ListSide sourceSide, StackExchange.Redis.ListSide destinationSide, long count, StackExchange.Redis.ListMoveCount mode = StackExchange.Redis.ListMoveCount.UpTo, StackExchange.Redis.ListMoveOrder order = StackExchange.Redis.ListMoveOrder.Bulk, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisValue[]? StackExchange.Redis.IDatabase.SetCombineLength(StackExchange.Redis.SetOperation operation, StackExchange.Redis.RedisKey[]! keys, long limit = 0, bool approximate = false, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> long StackExchange.Redis.IDatabase.StreamRead(StackExchange.Redis.StreamPosition[]! streamPositions, int? countPerStream = null, int? maxCount = null, int? maxSize = null, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.RedisStream[]! @@ -24,21 +24,15 @@ StackExchange.Redis.RedisFeatures.HashImport.get -> bool StackExchange.Redis.IDatabaseAsync.Database.get -> int [SER007]StackExchange.Redis.Availability.RetryPolicy [SER007]StackExchange.Redis.Availability.RetryPolicy.FailoverDelay.get -> System.TimeSpan -[SER007]StackExchange.Redis.Availability.RetryPolicy.FailoverDelay.set -> void [SER007]StackExchange.Redis.Availability.RetryPolicy.JitterMax.get -> System.TimeSpan -[SER007]StackExchange.Redis.Availability.RetryPolicy.JitterMax.set -> void [SER007]StackExchange.Redis.Availability.RetryPolicy.MaxAttempts.get -> int -[SER007]StackExchange.Redis.Availability.RetryPolicy.MaxAttempts.set -> void [SER007]StackExchange.Redis.Availability.RetryPolicy.MaxAttemptsBeforeFailover.get -> int -[SER007]StackExchange.Redis.Availability.RetryPolicy.MaxAttemptsBeforeFailover.set -> void [SER007]StackExchange.Redis.Availability.RetryPolicy.MaxCommandRetryCategory.get -> StackExchange.Redis.CommandFlags -[SER007]StackExchange.Redis.Availability.RetryPolicy.MaxCommandRetryCategory.set -> void [SER007]StackExchange.Redis.Availability.RetryPolicy.RetryDelay.get -> System.TimeSpan -[SER007]StackExchange.Redis.Availability.RetryPolicy.RetryDelay.set -> void [SER007]StackExchange.Redis.Availability.RetryPolicy.RetryPolicy() -> void StackExchange.Redis.IServer.InventKey(StackExchange.Redis.RedisKey prefix = default(StackExchange.Redis.RedisKey)) -> StackExchange.Redis.RedisKey StackExchange.Redis.Availability.DatabaseExtensions -[SER007]static StackExchange.Redis.Availability.DatabaseExtensions.WithRetry(this StackExchange.Redis.IDatabaseAsync! database, StackExchange.Redis.Availability.RetryPolicy! retryPolicy) -> StackExchange.Redis.IDatabaseAsync! +[SER007]static StackExchange.Redis.Availability.DatabaseExtensions.WithRetry(this StackExchange.Redis.IDatabaseAsync! database, StackExchange.Redis.Availability.RetryPolicy? retryPolicy = null) -> StackExchange.Redis.IDatabaseAsync! [SER007]StackExchange.Redis.IDatabaseAsync.CreateTransaction(object? asyncState = null) -> StackExchange.Redis.ITransactionAsync! StackExchange.Redis.ITransactionAsync StackExchange.Redis.ITransactionAsync.AddCondition(StackExchange.Redis.Condition! condition) -> StackExchange.Redis.ConditionResult! @@ -79,40 +73,9 @@ StackExchange.Redis.ITransactionAsync.ExecuteAsync(StackExchange.Redis.CommandFl [SER007]StackExchange.Redis.Availability.GroupConnectionChangedEventArgs.PreviousGroup.get -> StackExchange.Redis.Availability.ConnectionGroupMember? [SER007]StackExchange.Redis.Availability.GroupConnectionChangedEventArgs.Type.get -> StackExchange.Redis.Availability.GroupConnectionChangedEventArgs.ChangeType [SER007]StackExchange.Redis.Availability.HealthCheck -[SER007]StackExchange.Redis.Availability.HealthCheck.CheckHealthAsync(StackExchange.Redis.IConnectionMultiplexer! multiplexer) -> System.Threading.Tasks.Task! -[SER007]StackExchange.Redis.Availability.HealthCheck.CheckHealthAsync(StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! -[SER007]StackExchange.Redis.Availability.HealthCheck.Clone() -> StackExchange.Redis.Availability.HealthCheck! -[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheck() -> void -[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe -[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.HealthCheckProbe() -> void -[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext -[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext.Failure.get -> int -[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext.HealthCheckProbeContext() -> void -[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext.HealthCheckProbeContext(StackExchange.Redis.Availability.HealthCheck.HealthCheckResult result, int success, int failure, int remaining, System.TimeSpan probeInterval) -> void -[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext.ProbeInterval.get -> System.TimeSpan -[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext.Remaining.get -> int -[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext.Result.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckResult -[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext.Success.get -> int -[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy -[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy.HealthCheckProbePolicy() -> void -[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckResult -[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckResult.Healthy = 1 -> StackExchange.Redis.Availability.HealthCheck.HealthCheckResult -[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckResult.Inconclusive = 0 -> StackExchange.Redis.Availability.HealthCheck.HealthCheckResult -[SER007]StackExchange.Redis.Availability.HealthCheck.HealthCheckResult.Unhealthy = 2 -> StackExchange.Redis.Availability.HealthCheck.HealthCheckResult -[SER007]StackExchange.Redis.Availability.HealthCheck.Interval.get -> System.TimeSpan -[SER007]StackExchange.Redis.Availability.HealthCheck.Interval.set -> void -[SER007]StackExchange.Redis.Availability.HealthCheck.KeyWriteHealthCheckProbe -[SER007]StackExchange.Redis.Availability.HealthCheck.KeyWriteHealthCheckProbe.KeyWriteHealthCheckProbe() -> void -[SER007]StackExchange.Redis.Availability.HealthCheck.Probe.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe! -[SER007]StackExchange.Redis.Availability.HealthCheck.Probe.set -> void [SER007]StackExchange.Redis.Availability.HealthCheck.ProbeCount.get -> int -[SER007]StackExchange.Redis.Availability.HealthCheck.ProbeCount.set -> void [SER007]StackExchange.Redis.Availability.HealthCheck.ProbeInterval.get -> System.TimeSpan -[SER007]StackExchange.Redis.Availability.HealthCheck.ProbeInterval.set -> void -[SER007]StackExchange.Redis.Availability.HealthCheck.ProbePolicy.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy! -[SER007]StackExchange.Redis.Availability.HealthCheck.ProbePolicy.set -> void [SER007]StackExchange.Redis.Availability.HealthCheck.ProbeTimeout.get -> System.TimeSpan -[SER007]StackExchange.Redis.Availability.HealthCheck.ProbeTimeout.set -> void [SER007]StackExchange.Redis.Availability.IConnectionGroup [SER007]StackExchange.Redis.Availability.IConnectionGroup.ActiveMember.get -> StackExchange.Redis.Availability.ConnectionGroupMember? [SER007]StackExchange.Redis.Availability.IConnectionGroup.AddAsync(StackExchange.Redis.Availability.ConnectionGroupMember! member, System.IO.TextWriter? log = null) -> System.Threading.Tasks.Task! @@ -122,41 +85,21 @@ StackExchange.Redis.ITransactionAsync.ExecuteAsync(StackExchange.Redis.CommandFl [SER007]StackExchange.Redis.Availability.IConnectionGroup.TryFailoverTo(StackExchange.Redis.Availability.ConnectionGroupMember? member) -> bool [SER007]StackExchange.Redis.Availability.MultiGroupOptions [SER007]StackExchange.Redis.Availability.MultiGroupOptions.CircuitBreaker.get -> StackExchange.Redis.Availability.CircuitBreaker! -[SER007]StackExchange.Redis.Availability.MultiGroupOptions.CircuitBreaker.set -> void [SER007]StackExchange.Redis.Availability.MultiGroupOptions.HealthCheck.get -> StackExchange.Redis.Availability.HealthCheck! -[SER007]StackExchange.Redis.Availability.MultiGroupOptions.HealthCheck.set -> void -[SER007]StackExchange.Redis.Availability.MultiGroupOptions.MultiGroupOptions() -> void [SER007]static StackExchange.Redis.ConnectionMultiplexer.ConnectGroupAsync(StackExchange.Redis.Availability.ConnectionGroupMember! member0, StackExchange.Redis.Availability.ConnectionGroupMember! member1, StackExchange.Redis.Availability.MultiGroupOptions? options = null, System.IO.TextWriter? log = null) -> System.Threading.Tasks.Task! [SER007]static StackExchange.Redis.ConnectionMultiplexer.ConnectGroupAsync(StackExchange.Redis.Availability.ConnectionGroupMember![]! members, StackExchange.Redis.Availability.MultiGroupOptions? options = null, System.IO.TextWriter? log = null) -> System.Threading.Tasks.Task! [SER007]StackExchange.Redis.ConfigurationOptions.CircuitBreaker.get -> StackExchange.Redis.Availability.CircuitBreaker? [SER007]StackExchange.Redis.ConfigurationOptions.CircuitBreaker.set -> void -[SER007]StackExchange.Redis.ConfigurationOptions.HealthCheck.get -> StackExchange.Redis.Availability.HealthCheck? -[SER007]StackExchange.Redis.ConfigurationOptions.HealthCheck.set -> void [SER007]StackExchange.Redis.ConnectionFailureType.CircuitBreaker = 11 -> StackExchange.Redis.ConnectionFailureType [SER007]StackExchange.Redis.Availability.ConnectionGroupMember.IsUnhealthy.get -> bool [SER007]StackExchange.Redis.Availability.ConnectionGroupMember.ResetIsUnhealthy() -> void [SER007]StackExchange.Redis.Availability.MultiGroupOptions.FailbackDelay.get -> System.TimeSpan -[SER007]StackExchange.Redis.Availability.MultiGroupOptions.FailbackDelay.set -> void [SER007]abstract StackExchange.Redis.Availability.CircuitBreaker.Accumulator.IsHealthy() -> bool [SER007]abstract StackExchange.Redis.Availability.CircuitBreaker.Accumulator.Reset() -> void [SER007]abstract StackExchange.Redis.Availability.CircuitBreaker.CreateAccumulator() -> StackExchange.Redis.Availability.CircuitBreaker.Accumulator! [SER007]static StackExchange.Redis.Availability.CircuitBreaker.Default.get -> StackExchange.Redis.Availability.CircuitBreaker! [SER007]static StackExchange.Redis.Availability.CircuitBreaker.None.get -> StackExchange.Redis.Availability.CircuitBreaker! [SER007]static StackExchange.Redis.Availability.CircuitBreaker.Builder.implicit operator StackExchange.Redis.Availability.CircuitBreaker!(StackExchange.Redis.Availability.CircuitBreaker.Builder! builder) -> StackExchange.Redis.Availability.CircuitBreaker! -[SER007]abstract StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.CheckHealthAsync(StackExchange.Redis.Availability.HealthCheck! healthCheck, StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! -[SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.HealthyTask.get -> System.Threading.Tasks.Task! -[SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.InconclusiveTask.get -> System.Threading.Tasks.Task! -[SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.UnhealthyTask.get -> System.Threading.Tasks.Task! -[SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.IsConnected.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe! -[SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.Ping.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe! -[SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe.StringSet.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbe! -[SER007]abstract StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy.Evaluate(in StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext context) -> StackExchange.Redis.Availability.HealthCheck.HealthCheckResult -[SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy.AllSuccess.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy! -[SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy.AnySuccess.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy! -[SER007]static StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy.MajoritySuccess.get -> StackExchange.Redis.Availability.HealthCheck.HealthCheckProbePolicy! -[SER007]abstract StackExchange.Redis.Availability.HealthCheck.KeyWriteHealthCheckProbe.CheckHealthAsync(StackExchange.Redis.Availability.HealthCheck! healthCheck, StackExchange.Redis.IDatabaseAsync! database, StackExchange.Redis.RedisKey key) -> System.Threading.Tasks.Task! -[SER007]override StackExchange.Redis.Availability.HealthCheck.KeyWriteHealthCheckProbe.CheckHealthAsync(StackExchange.Redis.Availability.HealthCheck! healthCheck, StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! -[SER007]override StackExchange.Redis.Availability.HealthCheck.HealthCheckProbeContext.ToString() -> string! [SER007]static StackExchange.Redis.Availability.HealthCheck.Default.get -> StackExchange.Redis.Availability.HealthCheck! [SER007]static StackExchange.Redis.Availability.MultiGroupOptions.Default.get -> StackExchange.Redis.Availability.MultiGroupOptions! [SER007]override StackExchange.Redis.Availability.ConnectionGroupMember.ToString() -> string! @@ -197,11 +140,6 @@ StackExchange.Redis.ITransactionAsync.ExecuteAsync(StackExchange.Redis.CommandFl [SER007]StackExchange.Redis.RedisErrorKind.WrongType = 22 -> StackExchange.Redis.RedisErrorKind [SER007]abstract StackExchange.Redis.Availability.CircuitBreaker.Accumulator.ObserveResult(in StackExchange.Redis.Availability.FaultContext fault) -> void [SER007]virtual StackExchange.Redis.Availability.CircuitBreaker.Accumulator.IsFailure(in StackExchange.Redis.Availability.FaultContext fault) -> bool -[SER007]virtual StackExchange.Redis.Availability.RetryPolicy.CanRetry(in StackExchange.Redis.Availability.FaultContext fault) -> StackExchange.Redis.Availability.RetryPolicy.RetryPolicyResult -[SER007]StackExchange.Redis.Availability.RetryPolicy.RetryPolicyResult -[SER007]StackExchange.Redis.Availability.RetryPolicy.RetryPolicyResult.None = 0 -> StackExchange.Redis.Availability.RetryPolicy.RetryPolicyResult -[SER007]StackExchange.Redis.Availability.RetryPolicy.RetryPolicyResult.SameServer = 1 -> StackExchange.Redis.Availability.RetryPolicy.RetryPolicyResult -[SER007]StackExchange.Redis.Availability.RetryPolicy.RetryPolicyResult.FailoverServer = 2 -> StackExchange.Redis.Availability.RetryPolicy.RetryPolicyResult [SER007]StackExchange.Redis.CommandFlags.CommandRetryAlways = 8192 -> StackExchange.Redis.CommandFlags [SER007]StackExchange.Redis.CommandFlags.CommandRetryConnection = 32768 -> StackExchange.Redis.CommandFlags [SER007]StackExchange.Redis.CommandFlags.CommandRetryReadOnly = 65536 -> StackExchange.Redis.CommandFlags @@ -217,3 +155,117 @@ StackExchange.Redis.RedisServerException.Kind.get -> StackExchange.Redis.RedisEr StackExchange.Redis.RedisServerException.RedisServerException(StackExchange.Redis.RedisErrorKind kind, StackExchange.Redis.CommandFlags flags, string! message) -> void StackExchange.Redis.RedisTimeoutException.Flags.get -> StackExchange.Redis.CommandFlags StackExchange.Redis.RedisTimeoutException.RedisTimeoutException(StackExchange.Redis.CommandFlags flags, string! message, StackExchange.Redis.CommandStatus commandStatus) -> void +[SER007]abstract StackExchange.Redis.Availability.HealthCheckProbe.CheckHealthAsync(StackExchange.Redis.Availability.HealthCheckContext context) -> System.Threading.Tasks.Task! +[SER007]abstract StackExchange.Redis.Availability.HealthCheckProbePolicy.Evaluate(in StackExchange.Redis.Availability.HealthCheckProbeContext context) -> StackExchange.Redis.Availability.HealthCheckResult +[SER007]abstract StackExchange.Redis.Availability.KeyWriteHealthCheckProbe.CheckHealthAsync(StackExchange.Redis.Availability.HealthCheckContext context, StackExchange.Redis.IDatabaseAsync! database, StackExchange.Redis.RedisKey key) -> System.Threading.Tasks.Task! +[SER007]override sealed StackExchange.Redis.Availability.KeyWriteHealthCheckProbe.CheckHealthAsync(StackExchange.Redis.Availability.HealthCheckContext context) -> System.Threading.Tasks.Task! +[SER007]override StackExchange.Redis.Availability.HealthCheckContext.ToString() -> string! +[SER007]override StackExchange.Redis.Availability.HealthCheckProbeContext.ToString() -> string! +[SER007]override StackExchange.Redis.Availability.HealthCheck.ToString() -> string! +[SER007]override StackExchange.Redis.Availability.MultiGroupOptions.ToString() -> string! +[SER007]override StackExchange.Redis.Availability.RetryPolicy.ToString() -> string! +[SER007]StackExchange.Redis.Availability.ConnectionGroupMember.CircuitBreaker.get -> StackExchange.Redis.Availability.CircuitBreaker? +[SER007]StackExchange.Redis.Availability.ConnectionGroupMember.CircuitBreaker.set -> void +[SER007]StackExchange.Redis.Availability.ConnectionGroupMember.FailbackDelay.get -> System.TimeSpan? +[SER007]StackExchange.Redis.Availability.ConnectionGroupMember.FailbackDelay.set -> void +[SER007]StackExchange.Redis.Availability.ConnectionGroupMember.HealthCheck.get -> StackExchange.Redis.Availability.HealthCheck? +[SER007]StackExchange.Redis.Availability.ConnectionGroupMember.HealthCheck.set -> void +[SER007]StackExchange.Redis.Availability.HealthCheck.Builder +[SER007]StackExchange.Redis.Availability.HealthCheck.Builder.Builder() -> void +[SER007]StackExchange.Redis.Availability.HealthCheck.Builder.Builder(StackExchange.Redis.Availability.HealthCheck! healthCheck) -> void +[SER007]StackExchange.Redis.Availability.HealthCheck.Builder.Create() -> StackExchange.Redis.Availability.HealthCheck! +[SER007]StackExchange.Redis.Availability.HealthCheck.Builder.ProbeCount.get -> int +[SER007]StackExchange.Redis.Availability.HealthCheck.Builder.ProbeCount.set -> void +[SER007]StackExchange.Redis.Availability.HealthCheck.Builder.Probe.get -> StackExchange.Redis.Availability.HealthCheckProbe! +[SER007]StackExchange.Redis.Availability.HealthCheck.Builder.ProbeInterval.get -> System.TimeSpan +[SER007]StackExchange.Redis.Availability.HealthCheck.Builder.ProbeInterval.set -> void +[SER007]StackExchange.Redis.Availability.HealthCheck.Builder.ProbePolicy.get -> StackExchange.Redis.Availability.HealthCheckProbePolicy! +[SER007]StackExchange.Redis.Availability.HealthCheck.Builder.ProbePolicy.set -> void +[SER007]StackExchange.Redis.Availability.HealthCheck.Builder.Probe.set -> void +[SER007]StackExchange.Redis.Availability.HealthCheck.Builder.ProbeTimeout.get -> System.TimeSpan +[SER007]StackExchange.Redis.Availability.HealthCheck.Builder.ProbeTimeout.set -> void +[SER007]StackExchange.Redis.Availability.HealthCheck.CheckHealthAsync(StackExchange.Redis.IConnectionMultiplexer! multiplexer) -> System.Threading.Tasks.Task! +[SER007]StackExchange.Redis.Availability.HealthCheck.CheckHealthAsync(StackExchange.Redis.IServer! server) -> System.Threading.Tasks.Task! +[SER007]StackExchange.Redis.Availability.HealthCheckContext +[SER007]StackExchange.Redis.Availability.HealthCheckContext.HealthCheckContext() -> void +[SER007]StackExchange.Redis.Availability.HealthCheckContext.HealthCheckContext(StackExchange.Redis.IServer! server, System.TimeSpan probeTimeout) -> void +[SER007]StackExchange.Redis.Availability.HealthCheckContext.ProbeTimeout.get -> System.TimeSpan +[SER007]StackExchange.Redis.Availability.HealthCheckContext.Server.get -> StackExchange.Redis.IServer! +[SER007]StackExchange.Redis.Availability.HealthCheck.IsEnabled.get -> bool +[SER007]StackExchange.Redis.Availability.HealthCheckProbe +[SER007]StackExchange.Redis.Availability.HealthCheckProbeContext +[SER007]StackExchange.Redis.Availability.HealthCheckProbeContext.Failure.get -> int +[SER007]StackExchange.Redis.Availability.HealthCheckProbeContext.HealthCheckProbeContext() -> void +[SER007]StackExchange.Redis.Availability.HealthCheckProbeContext.HealthCheckProbeContext(StackExchange.Redis.Availability.HealthCheckResult result, int success, int failure, int remaining, System.TimeSpan probeInterval) -> void +[SER007]StackExchange.Redis.Availability.HealthCheckProbeContext.ProbeInterval.get -> System.TimeSpan +[SER007]StackExchange.Redis.Availability.HealthCheckProbeContext.Remaining.get -> int +[SER007]StackExchange.Redis.Availability.HealthCheckProbeContext.Result.get -> StackExchange.Redis.Availability.HealthCheckResult +[SER007]StackExchange.Redis.Availability.HealthCheckProbeContext.Success.get -> int +[SER007]StackExchange.Redis.Availability.HealthCheck.Probe.get -> StackExchange.Redis.Availability.HealthCheckProbe! +[SER007]StackExchange.Redis.Availability.HealthCheckProbe.HealthCheckProbe() -> void +[SER007]StackExchange.Redis.Availability.HealthCheckProbePolicy +[SER007]StackExchange.Redis.Availability.HealthCheck.ProbePolicy.get -> StackExchange.Redis.Availability.HealthCheckProbePolicy! +[SER007]StackExchange.Redis.Availability.HealthCheckProbePolicy.HealthCheckProbePolicy() -> void +[SER007]StackExchange.Redis.Availability.HealthCheckResult +[SER007]StackExchange.Redis.Availability.HealthCheckResult.Healthy = 1 -> StackExchange.Redis.Availability.HealthCheckResult +[SER007]StackExchange.Redis.Availability.HealthCheckResult.Inconclusive = 0 -> StackExchange.Redis.Availability.HealthCheckResult +[SER007]StackExchange.Redis.Availability.HealthCheckResult.Unhealthy = 2 -> StackExchange.Redis.Availability.HealthCheckResult +[SER007]StackExchange.Redis.Availability.IConnectionGroup.Options.get -> StackExchange.Redis.Availability.MultiGroupOptions! +[SER007]StackExchange.Redis.Availability.KeyWriteHealthCheckProbe +[SER007]StackExchange.Redis.Availability.KeyWriteHealthCheckProbe.KeyWriteHealthCheckProbe() -> void +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.Builder +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.Builder.Builder() -> void +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.Builder.Builder(StackExchange.Redis.Availability.MultiGroupOptions! options) -> void +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.Builder.CircuitBreaker.get -> StackExchange.Redis.Availability.CircuitBreaker! +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.Builder.CircuitBreaker.set -> void +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.Builder.Create() -> StackExchange.Redis.Availability.MultiGroupOptions! +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.Builder.FailbackDelay.get -> System.TimeSpan +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.Builder.FailbackDelay.set -> void +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.Builder.HealthCheck.get -> StackExchange.Redis.Availability.HealthCheck! +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.Builder.HealthCheckInterval.get -> System.TimeSpan +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.Builder.HealthCheckInterval.set -> void +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.Builder.HealthCheck.set -> void +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.Builder.RetryPolicy.get -> StackExchange.Redis.Availability.RetryPolicy! +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.Builder.RetryPolicy.set -> void +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.HealthCheckInterval.get -> System.TimeSpan +[SER007]StackExchange.Redis.Availability.MultiGroupOptions.RetryPolicy.get -> StackExchange.Redis.Availability.RetryPolicy! +[SER007]StackExchange.Redis.Availability.RetryPolicy.Builder +[SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.Builder() -> void +[SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.Builder(StackExchange.Redis.Availability.RetryPolicy! policy) -> void +[SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.Create() -> StackExchange.Redis.Availability.RetryPolicy! +[SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.FailoverDelay.get -> System.TimeSpan +[SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.FailoverDelay.set -> void +[SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.JitterMax.get -> System.TimeSpan +[SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.JitterMax.set -> void +[SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.MaxAttemptsBeforeFailover.get -> int +[SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.MaxAttemptsBeforeFailover.set -> void +[SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.MaxAttempts.get -> int +[SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.MaxAttempts.set -> void +[SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.MaxCommandRetryCategory.get -> StackExchange.Redis.CommandFlags +[SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.MaxCommandRetryCategory.set -> void +[SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.RetryDelay.get -> System.TimeSpan +[SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.RetryDelay.set -> void +[SER007]StackExchange.Redis.Availability.RetryPolicy.RetryPolicy(StackExchange.Redis.Availability.RetryPolicy.Builder! builder) -> void +[SER007]StackExchange.Redis.Availability.RetryResult +[SER007]StackExchange.Redis.Availability.RetryResult.FailoverServer = 2 -> StackExchange.Redis.Availability.RetryResult +[SER007]StackExchange.Redis.Availability.RetryResult.None = 0 -> StackExchange.Redis.Availability.RetryResult +[SER007]StackExchange.Redis.Availability.RetryResult.SameServer = 1 -> StackExchange.Redis.Availability.RetryResult +[SER007]StackExchange.Redis.ConfigurationOptions.RetryPolicy.get -> StackExchange.Redis.Availability.RetryPolicy? +[SER007]StackExchange.Redis.ConfigurationOptions.RetryPolicy.set -> void +[SER007]static StackExchange.Redis.Availability.HealthCheck.Builder.implicit operator StackExchange.Redis.Availability.HealthCheck!(StackExchange.Redis.Availability.HealthCheck.Builder! builder) -> StackExchange.Redis.Availability.HealthCheck! +[SER007]static StackExchange.Redis.Availability.HealthCheck.None.get -> StackExchange.Redis.Availability.HealthCheck! +[SER007]static StackExchange.Redis.Availability.HealthCheckProbe.HealthyTask.get -> System.Threading.Tasks.Task! +[SER007]static StackExchange.Redis.Availability.HealthCheckProbe.InconclusiveTask.get -> System.Threading.Tasks.Task! +[SER007]static StackExchange.Redis.Availability.HealthCheckProbe.IsConnected.get -> StackExchange.Redis.Availability.HealthCheckProbe! +[SER007]static StackExchange.Redis.Availability.HealthCheckProbe.None.get -> StackExchange.Redis.Availability.HealthCheckProbe! +[SER007]static StackExchange.Redis.Availability.HealthCheckProbe.Ping.get -> StackExchange.Redis.Availability.HealthCheckProbe! +[SER007]static StackExchange.Redis.Availability.HealthCheckProbePolicy.AllSuccess.get -> StackExchange.Redis.Availability.HealthCheckProbePolicy! +[SER007]static StackExchange.Redis.Availability.HealthCheckProbePolicy.AnySuccess.get -> StackExchange.Redis.Availability.HealthCheckProbePolicy! +[SER007]static StackExchange.Redis.Availability.HealthCheckProbePolicy.MajoritySuccess.get -> StackExchange.Redis.Availability.HealthCheckProbePolicy! +[SER007]static StackExchange.Redis.Availability.HealthCheckProbe.StringSet.get -> StackExchange.Redis.Availability.HealthCheckProbe! +[SER007]static StackExchange.Redis.Availability.HealthCheckProbe.UnhealthyTask.get -> System.Threading.Tasks.Task! +[SER007]static StackExchange.Redis.Availability.MultiGroupOptions.Builder.implicit operator StackExchange.Redis.Availability.MultiGroupOptions!(StackExchange.Redis.Availability.MultiGroupOptions.Builder! builder) -> StackExchange.Redis.Availability.MultiGroupOptions! +[SER007]static StackExchange.Redis.Availability.RetryPolicy.Builder.implicit operator StackExchange.Redis.Availability.RetryPolicy!(StackExchange.Redis.Availability.RetryPolicy.Builder! builder) -> StackExchange.Redis.Availability.RetryPolicy! +[SER007]static StackExchange.Redis.Availability.RetryPolicy.Default.get -> StackExchange.Redis.Availability.RetryPolicy! +[SER007]static StackExchange.Redis.Availability.RetryPolicy.None.get -> StackExchange.Redis.Availability.RetryPolicy! +[SER007]virtual StackExchange.Redis.Availability.RetryPolicy.CanRetry(in StackExchange.Redis.Availability.FaultContext fault) -> StackExchange.Redis.Availability.RetryResult diff --git a/src/StackExchange.Redis/StackExchange.Redis.csproj b/src/StackExchange.Redis/StackExchange.Redis.csproj index 8d5f585ba8..51d4ca902e 100644 --- a/src/StackExchange.Redis/StackExchange.Redis.csproj +++ b/src/StackExchange.Redis/StackExchange.Redis.csproj @@ -68,6 +68,9 @@ HealthCheck.cs + + HealthCheckProbe.cs + MultiGroupSubscriber.cs diff --git a/tests/StackExchange.Redis.Tests/AvailabilityConfigTests.cs b/tests/StackExchange.Redis.Tests/AvailabilityConfigTests.cs new file mode 100644 index 0000000000..4328fcc58f --- /dev/null +++ b/tests/StackExchange.Redis.Tests/AvailabilityConfigTests.cs @@ -0,0 +1,333 @@ +using System; +using System.Threading.Tasks; +using StackExchange.Redis.Availability; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Covers the shape shared by every Availability configuration type: an immutable policy with static +/// Default/None, configured through a nested Builder that validates in Create() and collapses onto the +/// shared default when nothing was customized. +/// +public class AvailabilityConfigTests +{ + // ---- HealthCheck ---- + [Fact] + public void HealthCheck_UntouchedBuilder_CollapsesOntoDefault() + { + Assert.Same(HealthCheck.Default, new HealthCheck.Builder().Create()); + Assert.Same(HealthCheck.Default, new HealthCheck.Builder(HealthCheck.Default).Create()); + } + + [Fact] + public void HealthCheck_BuilderRoundTripsExistingInstance() + { + HealthCheck original = new HealthCheck.Builder + { + ProbeCount = 7, + ProbeTimeout = TimeSpan.FromSeconds(11), + ProbeInterval = TimeSpan.FromMilliseconds(250), + Probe = HealthCheckProbe.IsConnected, + ProbePolicy = HealthCheckProbePolicy.MajoritySuccess, + }; + + // the copy constructor is the replacement for the old Clone() + var copy = new HealthCheck.Builder(original).Create(); + + Assert.NotSame(original, copy); + Assert.Equal(original.ProbeCount, copy.ProbeCount); + Assert.Equal(original.ProbeTimeout, copy.ProbeTimeout); + Assert.Equal(original.ProbeInterval, copy.ProbeInterval); + Assert.Same(original.Probe, copy.Probe); + Assert.Same(original.ProbePolicy, copy.ProbePolicy); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void HealthCheck_RejectsNonPositiveProbeCount(int probeCount) + { + var builder = new HealthCheck.Builder { ProbeCount = probeCount }; + var ex = Assert.Throws(() => builder.Create()); + Assert.Equal(nameof(HealthCheck.Builder.ProbeCount), ex.ParamName); + } + + [Fact] + public void HealthCheck_RejectsNonPositiveProbeTimeout() + { + var builder = new HealthCheck.Builder { ProbeTimeout = TimeSpan.Zero }; + var ex = Assert.Throws(() => builder.Create()); + Assert.Equal(nameof(HealthCheck.Builder.ProbeTimeout), ex.ParamName); + } + + [Fact] + public void HealthCheck_RejectsNegativeProbeInterval() + { + var builder = new HealthCheck.Builder { ProbeInterval = TimeSpan.FromMilliseconds(-1) }; + var ex = Assert.Throws(() => builder.Create()); + Assert.Equal(nameof(HealthCheck.Builder.ProbeInterval), ex.ParamName); + } + + [Fact] + public void HealthCheck_RejectsUnrepresentableTotalBudget() + { + // ProbeCount x ProbeTimeout has to fit in int milliseconds; this used to overflow silently + var builder = new HealthCheck.Builder { ProbeCount = 1000, ProbeTimeout = TimeSpan.FromDays(30) }; + Assert.Throws(() => builder.Create()); + } + + [Fact] + public void HealthCheck_None_IsDisabledAndStable() + { + Assert.Same(HealthCheck.None, HealthCheck.None); + Assert.NotSame(HealthCheck.None, HealthCheck.Default); + Assert.False(HealthCheck.None.IsEnabled); + Assert.True(HealthCheck.Default.IsEnabled); + } + + [Fact] + public async Task HealthCheck_None_ReportsInconclusiveWithoutProbing() + { + // a null server would throw if the probe were actually invoked + Assert.Equal(HealthCheckResult.Inconclusive, await HealthCheck.None.CheckHealthAsync(server: null!)); + } + + // ---- RetryPolicy ---- + [Fact] + public void RetryPolicy_UntouchedBuilder_CollapsesOntoDefault() + { + Assert.Same(RetryPolicy.Default, new RetryPolicy.Builder().Create()); + Assert.Same(RetryPolicy.Default, new RetryPolicy.Builder(RetryPolicy.Default).Create()); + } + + [Fact] + public void RetryPolicy_BuilderRoundTripsExistingInstance() + { + RetryPolicy original = new RetryPolicy.Builder + { + MaxAttempts = 9, + MaxAttemptsBeforeFailover = 4, + RetryDelay = TimeSpan.FromMilliseconds(123), + JitterMax = TimeSpan.FromMilliseconds(45), + FailoverDelay = TimeSpan.FromSeconds(6), + MaxCommandRetryCategory = CommandFlags.CommandRetryWriteAccumulating, + }; + + var copy = new RetryPolicy.Builder(original).Create(); + + Assert.NotSame(original, copy); + Assert.Equal(original.MaxAttempts, copy.MaxAttempts); + Assert.Equal(original.MaxAttemptsBeforeFailover, copy.MaxAttemptsBeforeFailover); + Assert.Equal(original.RetryDelay, copy.RetryDelay); + Assert.Equal(original.JitterMax, copy.JitterMax); + Assert.Equal(original.FailoverDelay, copy.FailoverDelay); + Assert.Equal(original.MaxCommandRetryCategory, copy.MaxCommandRetryCategory); + } + + [Fact] + public void RetryPolicy_RejectsZeroAttempts() + { + var builder = new RetryPolicy.Builder { MaxAttempts = 0 }; + var ex = Assert.Throws(() => builder.Create()); + Assert.Equal(nameof(RetryPolicy.Builder.MaxAttempts), ex.ParamName); + } + + [Fact] + public void RetryPolicy_RejectsZeroAttemptsBeforeFailover() + { + // previously this silently disabled failover, and only threw later, from WithRetry + var builder = new RetryPolicy.Builder { MaxAttemptsBeforeFailover = 0 }; + var ex = Assert.Throws(() => builder.Create()); + Assert.Equal(nameof(RetryPolicy.Builder.MaxAttemptsBeforeFailover), ex.ParamName); + } + + [Fact] + public void RetryPolicy_RejectsNegativeDelays() + { + Assert.Equal( + nameof(RetryPolicy.Builder.RetryDelay), + Assert.Throws(() => new RetryPolicy.Builder { RetryDelay = TimeSpan.FromTicks(-1) }.Create()).ParamName); + Assert.Equal( + nameof(RetryPolicy.Builder.JitterMax), + Assert.Throws(() => new RetryPolicy.Builder { JitterMax = TimeSpan.FromTicks(-1) }.Create()).ParamName); + Assert.Equal( + nameof(RetryPolicy.Builder.FailoverDelay), + Assert.Throws(() => new RetryPolicy.Builder { FailoverDelay = TimeSpan.FromTicks(-1) }.Create()).ParamName); + } + + [Theory] + [InlineData(CommandFlags.None)] // no category at all + [InlineData(CommandFlags.FireAndForget)] // not a category + [InlineData(CommandFlags.CommandRetryReadOnly | CommandFlags.PreferReplica)] // category plus noise + public void RetryPolicy_RejectsInvalidRetryCategory(CommandFlags flags) + { + var builder = new RetryPolicy.Builder { MaxCommandRetryCategory = flags }; + var ex = Assert.Throws(() => builder.Create()); + Assert.Equal(nameof(RetryPolicy.Builder.MaxCommandRetryCategory), ex.ParamName); + } + + [Fact] + public void RetryPolicy_None_NeverRetries() + { + Assert.Same(RetryPolicy.None, RetryPolicy.None); + Assert.NotSame(RetryPolicy.None, RetryPolicy.Default); + + // a transient, retryable fault on a read-only command: the default policy retries, None does not + var fault = new FaultContext(new RedisConnectionException(ConnectionFailureType.SocketFailure, CommandFlags.None, "boom")); + Assert.Equal(RetryResult.None, RetryPolicy.None.CanRetry(in fault)); + } + + // ---- CircuitBreaker ---- + [Fact] + public void CircuitBreaker_RejectsOutOfRangeThreshold() + { + Assert.Equal( + nameof(CircuitBreaker.Builder.FailureRateThreshold), + Assert.Throws(() => new CircuitBreaker.Builder { FailureRateThreshold = 101 }.Create()).ParamName); + Assert.Equal( + nameof(CircuitBreaker.Builder.FailureRateThreshold), + Assert.Throws(() => new CircuitBreaker.Builder { FailureRateThreshold = -1 }.Create()).ParamName); + } + + [Fact] + public void CircuitBreaker_RejectsInvalidWindowAndMinimum() + { + Assert.Equal( + nameof(CircuitBreaker.Builder.MinimumNumberOfFailures), + Assert.Throws(() => new CircuitBreaker.Builder { MinimumNumberOfFailures = 0 }.Create()).ParamName); + Assert.Equal( + nameof(CircuitBreaker.Builder.MetricsWindowSize), + Assert.Throws(() => new CircuitBreaker.Builder { MetricsWindowSize = TimeSpan.Zero }.Create()).ParamName); + } + + // ---- MultiGroupOptions ---- + [Fact] + public void MultiGroupOptions_UntouchedBuilder_CollapsesOntoDefault() + { + Assert.Same(MultiGroupOptions.Default, new MultiGroupOptions.Builder().Create()); + Assert.Same(MultiGroupOptions.Default, new MultiGroupOptions.Builder(MultiGroupOptions.Default).Create()); + } + + [Fact] + public void MultiGroupOptions_DefaultsAreTheSharedPolicyDefaults() + { + var options = MultiGroupOptions.Default; + Assert.Same(HealthCheck.Default, options.HealthCheck); + Assert.Same(CircuitBreaker.Default, options.CircuitBreaker); + Assert.Same(RetryPolicy.Default, options.RetryPolicy); + Assert.Equal(TimeSpan.FromSeconds(5), options.HealthCheckInterval); + Assert.Equal(TimeSpan.Zero, options.FailbackDelay); + } + + [Fact] + public void MultiGroupOptions_RejectsInvalidIntervals() + { + Assert.Equal( + nameof(MultiGroupOptions.Builder.HealthCheckInterval), + Assert.Throws(() => new MultiGroupOptions.Builder { HealthCheckInterval = TimeSpan.Zero }.Create()).ParamName); + Assert.Equal( + nameof(MultiGroupOptions.Builder.FailbackDelay), + Assert.Throws(() => new MultiGroupOptions.Builder { FailbackDelay = TimeSpan.FromTicks(-1) }.Create()).ParamName); + + // MaxValue is the documented "never" sentinel for both, and must remain legal + MultiGroupOptions ok = new MultiGroupOptions.Builder + { + HealthCheckInterval = TimeSpan.MaxValue, + FailbackDelay = TimeSpan.MaxValue, + }; + Assert.Equal(TimeSpan.MaxValue, ok.HealthCheckInterval); + Assert.Equal(TimeSpan.MaxValue, ok.FailbackDelay); + } + + [Fact] + public void MultiGroupOptions_BuilderConvertsImplicitly() + { + // every Builder in the namespace supports this, so options can be written inline at the call-site + MultiGroupOptions options = new MultiGroupOptions.Builder { FailbackDelay = TimeSpan.FromMinutes(2) }; + Assert.Equal(TimeSpan.FromMinutes(2), options.FailbackDelay); + } + + // ---- per-member override resolution ---- + [Fact] + public void Member_ResolvesGroupDefaultsWhenNoOverride() + { + var member = new ConnectionGroupMember("localhost:6379"); + var options = MultiGroupOptions.Default; + + Assert.Same(options.HealthCheck, member.ResolveHealthCheck(options)); + Assert.Same(options.CircuitBreaker, member.ResolveCircuitBreaker(options)); + Assert.Equal(options.FailbackDelay, member.ResolveFailbackDelay(options)); + } + + [Fact] + public void Member_OverridesBeatGroupDefaults() + { + HealthCheck memberCheck = new HealthCheck.Builder { ProbeCount = 1 }; + CircuitBreaker memberBreaker = new CircuitBreaker.Builder { FailureRateThreshold = 42 }; + var member = new ConnectionGroupMember("localhost:6379") + { + HealthCheck = memberCheck, + CircuitBreaker = memberBreaker, + FailbackDelay = TimeSpan.FromMinutes(3), + }; + + var options = MultiGroupOptions.Default; + Assert.Same(memberCheck, member.ResolveHealthCheck(options)); + Assert.Same(memberBreaker, member.ResolveCircuitBreaker(options)); + Assert.Equal(TimeSpan.FromMinutes(3), member.ResolveFailbackDelay(options)); + } + + [Fact] + public void Member_CircuitBreakerFallsBackToItsOwnConfigurationBeforeTheGroup() + { + // precedence is: member override, then the member's own ConfigurationOptions, then the group default + CircuitBreaker fromConfig = new CircuitBreaker.Builder { FailureRateThreshold = 42 }; + var config = ConfigurationOptions.Parse("localhost:6379"); + config.CircuitBreaker = fromConfig; + + var member = new ConnectionGroupMember(config); + Assert.Same(fromConfig, member.ResolveCircuitBreaker(MultiGroupOptions.Default)); + + CircuitBreaker fromMember = new CircuitBreaker.Builder { FailureRateThreshold = 13 }; + member.CircuitBreaker = fromMember; + Assert.Same(fromMember, member.ResolveCircuitBreaker(MultiGroupOptions.Default)); + } + + [Fact] + public void GroupDefaultsAreNotWrittenBackIntoCallerConfiguration() + { + // callers may legitimately reuse a ConfigurationOptions across connections, so resolving a group + // default must not mutate it (this used to be a `config.CircuitBreaker ??= options.CircuitBreaker`) + var config = ConfigurationOptions.Parse("localhost:6379"); + var member = new ConnectionGroupMember(config); + + Assert.Same(MultiGroupOptions.Default.CircuitBreaker, member.ResolveCircuitBreaker(MultiGroupOptions.Default)); + Assert.Null(config.CircuitBreaker); + } + + // ---- WithRetry() policy resolution ---- + [Fact] + public async Task WithRetry_UsesConfiguredPolicyForASingleConnection() + { + RetryPolicy configured = new RetryPolicy.Builder { MaxAttempts = 7 }; + var config = ConfigurationOptions.Parse("localhost:6379"); + config.RetryPolicy = configured; + config.AbortOnConnectFail = false; + + await using var muxer = await ConnectionMultiplexer.ConnectAsync(config); + var retrying = Assert.IsType(muxer.GetDatabase().WithRetry()); + Assert.Same(configured, retrying.Policy); + } + + [Fact] + public async Task WithRetry_FallsBackToDefaultWhenNoneConfigured() + { + var config = ConfigurationOptions.Parse("localhost:6379"); + config.AbortOnConnectFail = false; + + await using var muxer = await ConnectionMultiplexer.ConnectAsync(config); + var retrying = Assert.IsType(muxer.GetDatabase().WithRetry()); + Assert.Same(RetryPolicy.Default, retrying.Policy); + } +} diff --git a/tests/StackExchange.Redis.Tests/ConfigTests.cs b/tests/StackExchange.Redis.Tests/ConfigTests.cs index 83c02bbcb3..d2f5c3c0bf 100644 --- a/tests/StackExchange.Redis.Tests/ConfigTests.cs +++ b/tests/StackExchange.Redis.Tests/ConfigTests.cs @@ -80,7 +80,6 @@ orderby name "defaultOptions", "defaultVersion", "EndPoints", - "HealthCheck", "heartbeatInterval", "keepAlive", "LibraryName", @@ -95,6 +94,7 @@ orderby name "RequestBufferPool", "ResponseBufferPool", "responseTimeout", + "RetryPolicy", "ServiceName", "SocketManager", #if !NETFRAMEWORK diff --git a/tests/StackExchange.Redis.Tests/ControllableProbe.cs b/tests/StackExchange.Redis.Tests/ControllableProbe.cs index 4d8e47dc77..4d5c5ae235 100644 --- a/tests/StackExchange.Redis.Tests/ControllableProbe.cs +++ b/tests/StackExchange.Redis.Tests/ControllableProbe.cs @@ -7,12 +7,12 @@ namespace StackExchange.Redis.Tests; // A health-check probe whose verdict is driven by the test: nominated endpoints report unhealthy on // demand, everything else is healthy. This keeps a specific member deselected deterministically, even // after its physical connection reconnects underneath us. -internal sealed class ControllableProbe : HealthCheck.HealthCheckProbe +internal sealed class ControllableProbe : HealthCheckProbe { private volatile EndPoint? _down; public void MarkDown(EndPoint endpoint) => _down = endpoint; - public override Task CheckHealthAsync(HealthCheck healthCheck, IServer server) - => Equals(server.EndPoint, _down) ? UnhealthyTask : HealthyTask; + public override Task CheckHealthAsync(HealthCheckContext context) + => Equals(context.Server.EndPoint, _down) ? UnhealthyTask : HealthyTask; } diff --git a/tests/StackExchange.Redis.Tests/HealthCheckPolicyUnitTests.cs b/tests/StackExchange.Redis.Tests/HealthCheckPolicyUnitTests.cs index 1bbcebb4e0..c8a30030e0 100644 --- a/tests/StackExchange.Redis.Tests/HealthCheckPolicyUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/HealthCheckPolicyUnitTests.cs @@ -1,6 +1,6 @@ using System; +using StackExchange.Redis.Availability; using Xunit; -using static StackExchange.Redis.Availability.HealthCheck; namespace StackExchange.Redis.Tests; diff --git a/tests/StackExchange.Redis.Tests/MultiGroupTests/BasicMultiGroupTests.cs b/tests/StackExchange.Redis.Tests/MultiGroupTests/BasicMultiGroupTests.cs index a99fcd724c..23eaaa7bd6 100644 --- a/tests/StackExchange.Redis.Tests/MultiGroupTests/BasicMultiGroupTests.cs +++ b/tests/StackExchange.Redis.Tests/MultiGroupTests/BasicMultiGroupTests.cs @@ -108,13 +108,13 @@ public enum InbuiltProbe [InlineData(InbuiltProbe.StringSet, ServerType.Cluster)] public async Task SelectByWeight(InbuiltProbe probe, ServerType serverType) { - var healthCheck = new HealthCheck + HealthCheck healthCheck = new HealthCheck.Builder { Probe = probe switch { - InbuiltProbe.IsConnected => HealthCheck.HealthCheckProbe.IsConnected, - InbuiltProbe.Ping => HealthCheck.HealthCheckProbe.Ping, - InbuiltProbe.StringSet => HealthCheck.HealthCheckProbe.StringSet, + InbuiltProbe.IsConnected => HealthCheckProbe.IsConnected, + InbuiltProbe.Ping => HealthCheckProbe.Ping, + InbuiltProbe.StringSet => HealthCheckProbe.StringSet, _ => throw new ArgumentOutOfRangeException(nameof(probe)), }, }; @@ -132,7 +132,7 @@ public async Task SelectByWeight(InbuiltProbe probe, ServerType serverType) new(server1.GetClientConfig()) { Weight = 9 }, new(server2.GetClientConfig()) { Weight = 3 }, ]; - var options = new MultiGroupOptions { HealthCheck = healthCheck }; + MultiGroupOptions options = new MultiGroupOptions.Builder { HealthCheck = healthCheck }; await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members, options); Assert.True(conn.IsConnected); var typed = Assert.IsType(conn); diff --git a/tests/StackExchange.Redis.Tests/MultiGroupTests/CircuitBreakerRerouteTests.cs b/tests/StackExchange.Redis.Tests/MultiGroupTests/CircuitBreakerRerouteTests.cs index abd442e60f..86d3dc01f0 100644 --- a/tests/StackExchange.Redis.Tests/MultiGroupTests/CircuitBreakerRerouteTests.cs +++ b/tests/StackExchange.Redis.Tests/MultiGroupTests/CircuitBreakerRerouteTests.cs @@ -42,13 +42,13 @@ public async Task CircuitBreakerTrip_ReroutesAwayFromMember() new(serverC.GetClientConfig(), "C") { Weight = 1 }, ]; - var options = new MultiGroupOptions + MultiGroupOptions options = new MultiGroupOptions.Builder { - HealthCheck = new HealthCheck + // enormous, so the poll loop cannot be what reroutes us during the test + HealthCheckInterval = TimeSpan.FromMinutes(30), + HealthCheck = new HealthCheck.Builder { Probe = probe, - // enormous, so the poll loop cannot be what reroutes us during the test - Interval = TimeSpan.FromMinutes(30), ProbeCount = 1, ProbeTimeout = TimeSpan.FromSeconds(5), }, diff --git a/tests/StackExchange.Redis.Tests/MultiGroupTests/GroupConfigResolutionTests.cs b/tests/StackExchange.Redis.Tests/MultiGroupTests/GroupConfigResolutionTests.cs new file mode 100644 index 0000000000..2aca640ba5 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/MultiGroupTests/GroupConfigResolutionTests.cs @@ -0,0 +1,102 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using StackExchange.Redis.Availability; +using Xunit; + +namespace StackExchange.Redis.Tests.MultiGroupTests; + +/// +/// Verifies how a live group resolves its configuration: that group defaults reach the members, that a +/// per-member override wins, and that none of this mutates the caller's . +/// +public class GroupConfigResolutionTests(ITestOutputHelper log) +{ + [Fact] + public async Task GroupExposesItsOptions() + { + using var server0 = new InProcessTestServer(log, endpoint: new DnsEndPoint("alpha", 6379)); + using var server1 = new InProcessTestServer(log, endpoint: new DnsEndPoint("beta", 6379)); + + MultiGroupOptions options = new MultiGroupOptions.Builder + { + FailbackDelay = TimeSpan.FromMinutes(4), + RetryPolicy = new RetryPolicy.Builder { MaxAttempts = 6 }, + }; + + ConnectionGroupMember[] members = [new(server0.GetClientConfig()), new(server1.GetClientConfig())]; + await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members, options); + + Assert.Same(options, conn.Options); + Assert.Equal(TimeSpan.FromMinutes(4), conn.Options.FailbackDelay); + } + + [Fact] + public async Task WithRetryUsesTheGroupPolicy() + { + using var server0 = new InProcessTestServer(log, endpoint: new DnsEndPoint("alpha", 6379)); + using var server1 = new InProcessTestServer(log, endpoint: new DnsEndPoint("beta", 6379)); + + RetryPolicy groupPolicy = new RetryPolicy.Builder { MaxAttempts = 6, RetryDelay = TimeSpan.Zero }; + MultiGroupOptions options = new MultiGroupOptions.Builder { RetryPolicy = groupPolicy }; + + ConnectionGroupMember[] members = [new(server0.GetClientConfig()), new(server1.GetClientConfig())]; + await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members, options); + + // the parameterless overload resolves the policy from the group it is attached to + var retrying = Assert.IsType(conn.GetDatabase().WithRetry()); + Assert.Same(groupPolicy, retrying.Policy); + + // ...and an explicit policy still wins + RetryPolicy explicitPolicy = new RetryPolicy.Builder { MaxAttempts = 2 }; + var explicitlyRetrying = Assert.IsType(conn.GetDatabase().WithRetry(explicitPolicy)); + Assert.Same(explicitPolicy, explicitlyRetrying.Policy); + } + + [Fact] + public async Task GroupCircuitBreakerReachesMembersWithoutMutatingCallerConfig() + { + using var server0 = new InProcessTestServer(log, endpoint: new DnsEndPoint("alpha", 6379)); + using var server1 = new InProcessTestServer(log, endpoint: new DnsEndPoint("beta", 6379)); + + CircuitBreaker groupBreaker = new CircuitBreaker.Builder { FailureRateThreshold = 42 }; + CircuitBreaker memberBreaker = new CircuitBreaker.Builder { FailureRateThreshold = 13 }; + + var config0 = server0.GetClientConfig(); + var config1 = server1.GetClientConfig(); + + MultiGroupOptions options = new MultiGroupOptions.Builder { CircuitBreaker = groupBreaker }; + ConnectionGroupMember[] members = [new(config0), new(config1) { CircuitBreaker = memberBreaker }]; + await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members, options); + + // the group default reached the first member's connection, and the override reached the second + Assert.Same(groupBreaker, AsMultiplexer(members[0]).EffectiveCircuitBreaker); + Assert.Same(memberBreaker, AsMultiplexer(members[1]).EffectiveCircuitBreaker); + + // ...and neither was written back into the caller's configuration, which remains reusable + Assert.Null(config0.CircuitBreaker); + Assert.Null(config1.CircuitBreaker); + + static ConnectionMultiplexer AsMultiplexer(ConnectionGroupMember member) => member.Multiplexer; + } + + [Fact] + public async Task DisabledHealthCheckLeavesMemberSelectableOnConnectivityAlone() + { + using var server0 = new InProcessTestServer(log, endpoint: new DnsEndPoint("alpha", 6379)); + using var server1 = new InProcessTestServer(log, endpoint: new DnsEndPoint("beta", 6379)); + + // HealthCheck.None performs no probes and reports Inconclusive, which is not Unhealthy - so a + // connected member stays eligible, and the higher weight still wins + MultiGroupOptions options = new MultiGroupOptions.Builder { HealthCheck = HealthCheck.None }; + ConnectionGroupMember[] members = [ + new(server0.GetClientConfig(), "alpha") { Weight = 1 }, + new(server1.GetClientConfig(), "beta") { Weight = 9 }, + ]; + + await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members, options); + Assert.True(conn.IsConnected); + Assert.Equal("beta", conn.ActiveMember?.Name); + Assert.All(members, member => Assert.False(member.IsUnhealthy)); + } +} diff --git a/tests/StackExchange.Redis.Tests/RetryTests/CommandRetryPolicyUnitTests.cs b/tests/StackExchange.Redis.Tests/RetryTests/CommandRetryPolicyUnitTests.cs index 0a7dbe6576..45720df2ed 100644 --- a/tests/StackExchange.Redis.Tests/RetryTests/CommandRetryPolicyUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/RetryTests/CommandRetryPolicyUnitTests.cs @@ -11,11 +11,11 @@ public class CommandRetryPolicyUnitTests // Builds a FaultContext for a spoofed server error of the given kind, carrying the given // command-flags, and asks the policy whether it may be retried. - private static RetryPolicy.RetryPolicyResult CanRetry(RedisErrorKind kind, CommandFlags flags, RetryPolicy? policy = null) + private static RetryResult CanRetry(RedisErrorKind kind, CommandFlags flags, RetryPolicy? policy = null) { // the exception carries both the Kind and the command-flags; FaultContext reads them back var fault = new FaultContext(new RedisServerException(kind, flags, kind.ToString())); - return (policy ?? new RetryPolicy()).CanRetry(in fault); + return (policy ?? RetryPolicy.Default).CanRetry(in fault); } // The command's retry-category is checked against the policy's max category: the default max is @@ -35,7 +35,7 @@ private static RetryPolicy.RetryPolicyResult CanRetry(RedisErrorKind kind, Comma public void CanRetry_CategoryVersusDefaultMax(CommandFlags category, bool expectRetry) { var result = CanRetry(RedisErrorKind.Loading, category); - Assert.Equal(expectRetry, result != RetryPolicy.RetryPolicyResult.None); + Assert.Equal(expectRetry, result != RetryResult.None); } // With an in-range category (== default max), the outcome is decided purely by whether the error @@ -48,7 +48,7 @@ public void CanRetry_CategoryVersusDefaultMax(CommandFlags category, bool expect public void CanRetry_ErrorKindGatesRetry_WhenInRange(RedisErrorKind kind, bool expectRetry) { var result = CanRetry(kind, CommandFlags.CommandRetryWriteLastWins); - Assert.Equal(expectRetry, result != RetryPolicy.RetryPolicyResult.None); + Assert.Equal(expectRetry, result != RetryResult.None); } // "never" and "always" adjust only the category range - they do not override the error-kind check: @@ -62,15 +62,15 @@ public void CanRetry_ErrorKindGatesRetry_WhenInRange(RedisErrorKind kind, bool e public void CanRetry_NeverAndAlwaysAffectRangeNotErrorKind(CommandFlags category, RedisErrorKind kind, bool expectRetry) { var result = CanRetry(kind, category); - Assert.Equal(expectRetry, result != RetryPolicy.RetryPolicyResult.None); + Assert.Equal(expectRetry, result != RetryResult.None); } // When a retry is permitted, it normally offers both the same server and a failover server; but a // "server specific" (sticky) command must not move endpoints, so only the same-server option remains. [Theory] - [InlineData(CommandFlags.None, RetryPolicy.RetryPolicyResult.SameServer | RetryPolicy.RetryPolicyResult.FailoverServer)] - [InlineData(Message.CommandServerSpecific, RetryPolicy.RetryPolicyResult.SameServer)] - public void CanRetry_ServerSpecificRestrictsToSameServer(CommandFlags extra, RetryPolicy.RetryPolicyResult expected) + [InlineData(CommandFlags.None, RetryResult.SameServer | RetryResult.FailoverServer)] + [InlineData(Message.CommandServerSpecific, RetryResult.SameServer)] + public void CanRetry_ServerSpecificRestrictsToSameServer(CommandFlags extra, RetryResult expected) { // in-range category (== default max) + transient error => a retry is offered; the sticky flag // only changes *where* the retry may go, not *whether* it happens. @@ -89,8 +89,8 @@ public void CanRetry_ServerSpecificDoesNotAffectRange(CommandFlags category, boo var withoutFlag = CanRetry(RedisErrorKind.Loading, category); var withFlag = CanRetry(RedisErrorKind.Loading, category | Message.CommandServerSpecific); - Assert.Equal(expectRetry, withoutFlag != RetryPolicy.RetryPolicyResult.None); - Assert.Equal(expectRetry, withFlag != RetryPolicy.RetryPolicyResult.None); + Assert.Equal(expectRetry, withoutFlag != RetryResult.None); + Assert.Equal(expectRetry, withFlag != RetryResult.None); } // --- RetryDatabase.CanRetry: attempt accounting ---------------------------------- @@ -107,7 +107,7 @@ public void CanRetry_ServerSpecificDoesNotAffectRange(CommandFlags category, boo [InlineData(3, false)] public void RetryDatabase_CanRetry_MaxAttempts_NoFailover(int attempt, bool expected) { - var policy = new RetryPolicy { MaxAttempts = 3, MaxAttemptsBeforeFailover = 3 }; + RetryPolicy policy = new RetryPolicy.Builder { MaxAttempts = 3, MaxAttemptsBeforeFailover = 3 }; var controller = new RetryController(policy, DatabaseFeatureFlags.None); // CanRetry never touches any database using var cts = new CancellationTokenSource(); @@ -130,7 +130,7 @@ public void RetryDatabase_CanRetry_MaxAttempts_NoFailover(int attempt, bool expe [Fact] public void RetryDatabase_CanRetry_FailoverAtThreshold() { - var policy = new RetryPolicy { MaxAttempts = 4, MaxAttemptsBeforeFailover = 2 }; + RetryPolicy policy = new RetryPolicy.Builder { MaxAttempts = 4, MaxAttemptsBeforeFailover = 2 }; // failover is only armed when the inner database advertises the feature; supply it explicitly var controller = new RetryController(policy, DatabaseFeatureFlags.Failover); @@ -169,7 +169,7 @@ public void RetryDatabase_CanRetry_FailoverAtThreshold() [Fact] public void RetryDatabase_CanRetry_ServerSpecific_CannotFailover() { - var policy = new RetryPolicy { MaxAttempts = 4, MaxAttemptsBeforeFailover = 2 }; + RetryPolicy policy = new RetryPolicy.Builder { MaxAttempts = 4, MaxAttemptsBeforeFailover = 2 }; var controller = new RetryController(policy, DatabaseFeatureFlags.Failover); using var cts = new CancellationTokenSource(); diff --git a/tests/StackExchange.Redis.Tests/RetryTests/RetryEndToEndTests.cs b/tests/StackExchange.Redis.Tests/RetryTests/RetryEndToEndTests.cs index b7b2202de2..414823af0c 100644 --- a/tests/StackExchange.Redis.Tests/RetryTests/RetryEndToEndTests.cs +++ b/tests/StackExchange.Redis.Tests/RetryTests/RetryEndToEndTests.cs @@ -30,7 +30,7 @@ public async Task WithRetry_RidesOutTransientLoading() server.LoadingOps = 2; // zero delay/jitter so the test isn't paying the default ~1s retry backoff between attempts - var policy = new RetryPolicy + RetryPolicy policy = new RetryPolicy.Builder { MaxAttempts = 3, RetryDelay = TimeSpan.Zero, @@ -86,12 +86,12 @@ public async Task WithRetry_FailsOverBetweenGroupsOnLoading() new(serverB.GetClientConfig(), "B") { Weight = 1 }, // failover target ]; - var options = new MultiGroupOptions + MultiGroupOptions options = new MultiGroupOptions.Builder { - HealthCheck = new HealthCheck + HealthCheckInterval = TimeSpan.FromMinutes(30), // huge: the breaker fast-path is what reroutes us + HealthCheck = new HealthCheck.Builder { Probe = probe, - Interval = TimeSpan.FromMinutes(30), // huge: the breaker fast-path is what reroutes us ProbeCount = 1, ProbeTimeout = TimeSpan.FromSeconds(5), }, @@ -102,7 +102,7 @@ public async Task WithRetry_FailsOverBetweenGroupsOnLoading() Assert.Same(members[0], conn.ActiveMember); // A is active (highest weight) // failover enabled, plenty of attempts, no artificial delay between them - var policy = new RetryPolicy + RetryPolicy policy = new RetryPolicy.Builder { MaxAttempts = 20, MaxAttemptsBeforeFailover = 1, @@ -143,7 +143,7 @@ public async Task WithRetry_Transaction_RidesOutTransientExec() server.FailExecOps = 1; // fail the first EXEC; the second should commit - var policy = new RetryPolicy + RetryPolicy policy = new RetryPolicy.Builder { MaxAttempts = 3, RetryDelay = TimeSpan.Zero, @@ -180,7 +180,7 @@ public async Task WithRetry_Transaction_AccumulatingOp_RespectsCategoryGate() RedisKey key = "retry:tran:incr"; // default cap = write-last-wins; an INCR makes the aggregate accumulating -> NOT retried - var conservative = new RetryPolicy { MaxAttempts = 3, RetryDelay = TimeSpan.Zero, JitterMax = TimeSpan.Zero }; + RetryPolicy conservative = new RetryPolicy.Builder { MaxAttempts = 3, RetryDelay = TimeSpan.Zero, JitterMax = TimeSpan.Zero }; server.FailExecOps = 1; var tran1 = db.WithRetry(conservative).CreateTransaction(); var incr1 = tran1.StringIncrementAsync(key); @@ -190,7 +190,7 @@ public async Task WithRetry_Transaction_AccumulatingOp_RespectsCategoryGate() Assert.Equal(0, server.FailExecOps); // raise the cap to allow accumulating writes: the same transaction now rides out the transient failure - var permissive = new RetryPolicy + RetryPolicy permissive = new RetryPolicy.Builder { MaxAttempts = 3, RetryDelay = TimeSpan.Zero, @@ -222,7 +222,7 @@ public async Task WithRetry_Transaction_SatisfiedCondition_RidesOutTransientExec server.FailExecOps = 1; // fail the first EXEC; the condition must still hold on the replay - var policy = new RetryPolicy { MaxAttempts = 3, RetryDelay = TimeSpan.Zero, JitterMax = TimeSpan.Zero }; + RetryPolicy policy = new RetryPolicy.Builder { MaxAttempts = 3, RetryDelay = TimeSpan.Zero, JitterMax = TimeSpan.Zero }; var tran = db.WithRetry(policy).CreateTransaction(); var cond = tran.AddCondition(Condition.StringEqual(key, "seed")); // satisfied on both attempts var setTask = tran.StringSetAsync(key, "committed"); @@ -251,7 +251,7 @@ public async Task WithRetry_Transaction_UnsatisfiedCondition_AbortsWithoutRetry( server.FailExecOps = 0; // no transient fault; the condition itself aborts the transaction - var policy = new RetryPolicy { MaxAttempts = 3, RetryDelay = TimeSpan.Zero, JitterMax = TimeSpan.Zero }; + RetryPolicy policy = new RetryPolicy.Builder { MaxAttempts = 3, RetryDelay = TimeSpan.Zero, JitterMax = TimeSpan.Zero }; var tran = db.WithRetry(policy).CreateTransaction(); var cond = tran.AddCondition(Condition.StringEqual(key, "different")); // NOT satisfied var setTask = tran.StringSetAsync(key, "committed"); @@ -282,7 +282,7 @@ public async Task WithRetry_Transaction_PerOpError_FaultsOnlyThatProxy() server.FailExecOps = 0; // EXEC commits; one queued op errors at execution time // allow accumulating so the INCR doesn't gate retries - though nothing here retries anyway (EXEC commits) - var policy = new RetryPolicy + RetryPolicy policy = new RetryPolicy.Builder { MaxAttempts = 3, RetryDelay = TimeSpan.Zero, @@ -331,12 +331,12 @@ public async Task WithRetry_Transaction_FailsOverBetweenGroups() new(serverB.GetClientConfig(), "B") { Weight = 1 }, // failover target ]; - var options = new MultiGroupOptions + MultiGroupOptions options = new MultiGroupOptions.Builder { - HealthCheck = new HealthCheck + HealthCheckInterval = TimeSpan.FromMinutes(30), // huge: the breaker fast-path is what reroutes us + HealthCheck = new HealthCheck.Builder { Probe = probe, - Interval = TimeSpan.FromMinutes(30), // huge: the breaker fast-path is what reroutes us ProbeCount = 1, ProbeTimeout = TimeSpan.FromSeconds(5), }, @@ -346,7 +346,7 @@ public async Task WithRetry_Transaction_FailsOverBetweenGroups() Assert.True(conn.IsConnected); Assert.Same(members[0], conn.ActiveMember); // A is active (highest weight) - var policy = new RetryPolicy + RetryPolicy policy = new RetryPolicy.Builder { MaxAttempts = 20, MaxAttemptsBeforeFailover = 1, From 5a333e6c2b3826569f3cc430d08062e91a216cb4 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 30 Jul 2026 10:05:40 +0100 Subject: [PATCH 28/33] retry hardening (#3144) --- docs/ActiveActive.md | 33 +- .../Availability/DatabaseExtensions.cs | 14 +- .../Availability/FaultContext.cs | 48 ++ .../Availability/RetryController.cs | 19 +- .../Availability/RetryDatabase.cs | 29 +- .../Availability/RetryPolicy.cs | 49 +- .../Availability/RetryTransaction.cs | 37 +- src/StackExchange.Redis/ExceptionFactory.cs | 32 ++ .../Interfaces/IInternalDatabaseAsync.cs | 10 + .../Interfaces/ITransactionAsync.cs | 24 + .../KeyspaceIsolation/KeyPrefixed.cs | 3 + .../KeyPrefixedTransaction.cs | 3 + src/StackExchange.Redis/PhysicalBridge.cs | 8 +- src/StackExchange.Redis/PhysicalConnection.cs | 4 + .../PublicAPI/PublicAPI.Unshipped.txt | 5 + src/StackExchange.Redis/RedisTransaction.cs | 42 +- .../InProcessTestServer.cs | 8 +- .../RetryTests/CommandRetryPolicyUnitTests.cs | 56 +- .../RetryTests/ConnectionFaultDetailTests.cs | 153 ++++++ .../RetryTests/RetryControllerTests.cs | 206 ++++++++ .../RetryTests/RetryEndToEndTests.cs | 481 +++++++++++++++++- .../RetryTests/RetryGuardTests.cs | 182 +++++++ .../TransactionWatchDriftTests.cs | 189 +++++++ .../TypedRedisValue.cs | 15 +- 24 files changed, 1580 insertions(+), 70 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/RetryTests/ConnectionFaultDetailTests.cs create mode 100644 tests/StackExchange.Redis.Tests/RetryTests/RetryControllerTests.cs create mode 100644 tests/StackExchange.Redis.Tests/RetryTests/RetryGuardTests.cs create mode 100644 tests/StackExchange.Redis.Tests/TransactionWatchDriftTests.cs diff --git a/docs/ActiveActive.md b/docs/ActiveActive.md index e6328910e1..2bcc069a84 100644 --- a/docs/ActiveActive.md +++ b/docs/ActiveActive.md @@ -553,7 +553,24 @@ var value = await db.StringGetAsync("mykey"); > You can call `WithRetry` on any database (`IDatabase` or `IDatabaseAsync`), but the wrapper it returns exposes only the **async** API — there is no synchronous form, since retrying may inherently have delays. It cannot wrap a batch or an existing transaction, nor an already-retrying database. -A retrying database can still *create* a transaction: `retryDb.CreateTransaction()` returns an `ITransactionAsync` whose `ExecuteAsync` is retried as a single unit. Each attempt replays the queued operations (and any `WATCH` constraints) against a fresh `MULTI`/`EXEC` - and, in an Active:Active group, onto whichever member is active at the time - so a transaction can ride out a failover just like a single command; the per-operation tasks handed back at build time resolve from the winning attempt. The retry-category gate (below) still applies, using the *most* side-effecting operation in the transaction: a transaction containing an `INCR` is treated as `CommandRetryWriteAccumulating`, so the default policy will not retry it unless you raise `MaxCommandRetryCategory`. +> **`asyncState` is not respected.** A database's `asyncState` is stamped onto the task produced by a single dispatch, but a retrying database hands back its own task spanning however many attempts the operation takes, and the per-operation tasks from `retryDb.CreateTransaction()` are durable proxies that outlive any single attempt. Neither can carry it. Rather than dropping the state silently, both refuse it: `conn.GetDatabase(0, asyncState).WithRetry(policy)` throws `InvalidOperationException`, as does `retryDb.CreateTransaction(asyncState)`. Wrap a database obtained without an `asyncState` - note that a key-prefixed view of a state-carrying database is refused too, since it inherits the inner state. + +A retrying database can still *create* a transaction: `retryDb.CreateTransaction()` returns an `ITransactionAsync` whose `ExecuteAsync` is retried as a single unit. Each attempt replays the queued operations (and any `WATCH` constraints) against a fresh `MULTI`/`EXEC` - and, in an Active:Active group, onto whichever member is active at the time - so a transaction can ride out a failover just like a single command; the per-operation tasks handed back at build time resolve from the winning attempt. The retry-category gate (below) applies to the transaction as a whole, using the *most* side-effecting operation in it: a transaction containing an `INCR` counts as `CommandRetryWriteAccumulating`. In practice that gate rarely blocks a transaction, because the faults that a transaction is most likely to hit - a `MULTI`/`EXEC` the server *rejected* wholesale - are known not to have applied anything, and the category is not consulted in that case (see [Known-not-applied faults](#known-not-applied-faults)). + +#### Losing a `WATCH` race + +A transaction with conditions can fail to commit in two different ways, and `Execute`/`ExecuteAsync` reports `false` for both. `ITransaction.WasWatchConflict` (also on `ITransactionAsync`, so it works for retrying transactions too) is what tells them apart: + +- **a condition was not satisfied** - the transaction is abandoned electively, and no `EXEC` is ever sent. `WasWatchConflict` is `false`, and the offending `ConditionResult.WasSatisfied` is `false` too. Re-attempting is pointless: the value really was not what you asserted, so a replay would assert the same thing again and fail the same way. +- **another connection modified a watched key** - between this connection's conditions being evaluated and its `EXEC` arriving, some *other* client wrote to one of the keys being watched on behalf of those conditions, so the server refused the `EXEC`. `WasWatchConflict` is `true`, and `WasSatisfied` is `true` for every condition: your assertions were all correct, you just lost a race with a concurrent writer. This is contention, not a fault - nothing was applied and nothing is broken. + +Note that only *other connections* can cause this. Your own writes on this connection cannot: everything you queue inside the transaction is applied atomically by the `EXEC` itself, after the watch has already been satisfied. + +The second case is the one Redis's `WATCH`/`MULTI`/`EXEC` idiom expects you to retry, so a retrying transaction does exactly that, bounded by `MaxAttemptsOnWatchConflict` (default 3). Each re-attempt re-issues the constraints, so a transaction whose condition has genuinely stopped holding (because the concurrent writer left a value you were not expecting) converges on an ordinary elective abort rather than looping. Because it is contention rather than a fault it gets its own budget: `MaxAttempts` is untouched, `RetryDelay` is not applied (only `JitterMax`), no failover is attempted, and `MaxCommandRetryCategory` does not apply - nothing happened, so there is nothing to double-apply. Set `MaxAttemptsOnWatchConflict = 1` to restore the plain "report `false` and let me deal with it" behaviour. On a retrying transaction, `WasWatchConflict` describes the *final* attempt: `false` if it eventually committed, `true` if it ran out of attempts still losing the race. + +In either case every per-operation task is cancelled, so `await`ing one throws `OperationCanceledException` rather than hanging. + +How likely is this in practice? Much less so than in `redis-cli`-style usage, where a `WATCH` can sit open for as long as the application takes to think. SE.Redis buffers the whole transaction and sends the `WATCH`, the condition reads, the `MULTI`, the queued commands and the `EXEC` as a single dispatch on a multiplexed connection, so a competing writer has only the gap between the condition reads and the `EXEC` landing to squeeze into. Small - but not zero, and on a busy key it will happen. ### Configuring the retry policy @@ -584,6 +601,7 @@ The same works for a single connection via `ConfigurationOptions.RetryPolicy`. ` | `JitterMax` | 0.5 seconds | Upper bound of the additional random delay added to each retry, to avoid stampedes | | `FailoverDelay` | 5 seconds | Maximum time to wait for a failover, when a retry is gated on one happening | | `MaxCommandRetryCategory` | `CommandRetryWriteLastWins` | The most side-effecting command category that will be retried (see below) | +| `MaxAttemptsOnWatchConflict` | 3 | Attempts allowed for a *conditional transaction* that keeps losing a `WATCH` race; separate from `MaxAttempts`, and `1` disables re-attempting | ```csharp RetryPolicy policy = new RetryPolicy.Builder @@ -603,10 +621,23 @@ Retrying is not free of consequence: replaying `INCR` after an ambiguous failure For the built-in typed methods (`StringGet`, `StringSet`, `HashSet`, ...) the library assigns the appropriate category automatically, so retries "just work" within the default policy. +#### Known-not-applied faults + +The category prices the *ambiguity* of a replay, not the write itself. If we know the operation never took effect, re-issuing it is a first attempt rather than a repeat: it cannot double-apply anything, so the category is not consulted at all and even an `INCR` is retried under the default policy. Two things give us that certainty: + +- **the client never wrote it** - the message was still waiting to be sent, or sitting in the backlog, when the connection failed. +- **the server explicitly rejected it because of its own state** - `LOADING`, `CLUSTERDOWN`, `MASTERDOWN`, `TRYAGAIN`, `MOVED`, `ASK`, `READONLY`, `MISCONF`, `NOREPLICAS`, `BUSY`, `max clients`. The server can only report these *before* running anything. + +`FaultContext.NotApplied` exposes this to custom policies. It is deliberately conservative, and in particular a bare error reply is *not* enough on its own: a Lua script can fail part-way through having already written something, and it propagates the inner error (`WRONGTYPE`, `OOM`, ...) verbatim, so those kinds stay ambiguous. Timeouts, and connection loss after the request was sent, are always ambiguous - which is exactly where `MaxCommandRetryCategory` earns its keep. + +An explicit `CommandRetryNever` is still an absolute veto, as is an operation with no category at all (see below): certainty about *whether* it ran does not tell us that re-running it is meaningful. + ### Custom commands: `Execute` and `ScriptEvaluate` The library cannot infer the side-effects of a command it doesn't recognise — and that includes arbitrary commands issued via `Execute`/`ExecuteAsync`, and Lua run via `ScriptEvaluate`/`ScriptEvaluateAsync` (whose effect depends entirely on the script). Such commands are therefore treated **pessimistically**: an uncategorised command defaults to `CommandRetryNever` and is *not* retried. +Note that `Execute`/`ExecuteAsync` do try to *parse* the command name first, so `Execute("get", key)` is recognised as `GET` and picks up that command's category (read-only) automatically; only genuinely unrecognised command names fall back to `CommandRetryNever`. + The categories, from safest to most dangerous, are: | `CommandFlags` value | Meaning | diff --git a/src/StackExchange.Redis/Availability/DatabaseExtensions.cs b/src/StackExchange.Redis/Availability/DatabaseExtensions.cs index 6465288843..c082259d16 100644 --- a/src/StackExchange.Redis/Availability/DatabaseExtensions.cs +++ b/src/StackExchange.Redis/Availability/DatabaseExtensions.cs @@ -13,13 +13,25 @@ public static class DatabaseExtensions /// SE.Redis concepts, so can respond to server failover events, apply circuit-breaker rules, and /// respect command effect categorization. /// - /// The database to wrap. + /// The database to wrap; this must not be a batch, a transaction, an + /// already-retrying database, or a database carrying an asyncState (see remarks). /// /// The policy to apply; when (the default), the policy configured for the /// underlying connection is used - for a connection group, /// for a single connection, else /// . /// + /// + /// asyncState is not supported. A database's asyncState is stamped onto the task + /// produced by a single dispatch, but a retrying database hands back its own task spanning however + /// many attempts the operation takes; the same is true of the per-operation tasks handed out by + /// on such a database. Rather than dropping + /// the state silently, both refuse it: wrapping a database obtained via + /// GetDatabase(db, asyncState) throws, as does supplying an asyncState when creating a + /// transaction from a retrying database. + /// + /// If is a batch, a + /// transaction, already retrying, or carries an asyncState. [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] public static IDatabaseAsync WithRetry(this IDatabaseAsync database, RetryPolicy? retryPolicy = null) => new RetryDatabase(database, retryPolicy ?? ResolveRetryPolicy(database)); diff --git a/src/StackExchange.Redis/Availability/FaultContext.cs b/src/StackExchange.Redis/Availability/FaultContext.cs index 4321630aff..0c70f5d943 100644 --- a/src/StackExchange.Redis/Availability/FaultContext.cs +++ b/src/StackExchange.Redis/Availability/FaultContext.cs @@ -27,6 +27,7 @@ public FaultContext(Exception fault) var kind = RedisErrorKind.None; _connectionFailureType = ConnectionFailureType.None; var flags = CommandFlags.None; + var status = CommandStatus.Unknown; switch (fault) { case RedisServerException server: @@ -37,16 +38,20 @@ public FaultContext(Exception fault) _connectionFailureType = connection.FailureType; kind = RedisErrorKind.ConnectionFault; flags = connection.Flags; + status = connection.CommandStatus; break; case RedisTimeoutException timeout: kind = RedisErrorKind.Timeout; flags = timeout.Flags; + status = timeout.Commandstatus; break; case TimeoutException: kind = RedisErrorKind.Timeout; break; } + NotApplied = IsKnownNotApplied(kind, status); + if (kind is not RedisErrorKind.None & _connectionFailureType is ConnectionFailureType.None) { // fill in some blanks @@ -93,8 +98,51 @@ public FaultContext(Exception fault) /// public RedisErrorKind ErrorKind { get; } + /// + /// Indicates that the operation is known *not* to have been applied by the server - either because it + /// never left the client, or because the server explicitly rejected it due to its own state (still + /// loading, cluster down, writes refused, and so on). Retrying such an operation is a *first* attempt + /// rather than a repeat, so it cannot double-apply a side-effect; therefore + /// ignores in this case. + /// + /// + /// This is deliberately conservative: it is only reported for conditions that the server can *only* + /// raise before running anything (so a Lua script that failed part-way through cannot be mistaken for + /// one that never ran), and for messages the client knows it never wrote. Everything else - notably + /// timeouts, and connection loss after the request was sent - remains ambiguous and is not flagged. + /// + public bool NotApplied { get; } + /// /// The connection failure type associated with the fault, if any. /// public ConnectionFailureType ConnectionFailureType => _connectionFailureType; + + private static bool IsKnownNotApplied(RedisErrorKind kind, CommandStatus status) + { + // the client never handed it to the socket, so the server cannot have seen it + if (status is CommandStatus.WaitingToBeSent or CommandStatus.WaitingInBacklog) return true; + + // an error *reply* usually means the server declined to run the command, but not always: a Lua + // script can fail part-way through, having already applied earlier writes, and it propagates the + // inner error verbatim (WRONGTYPE, and so on). So we only trust the conditions that describe the + // *server's own state*, which it can only report before running anything. + switch (kind) + { + case RedisErrorKind.Loading: // still loading the dataset + case RedisErrorKind.ClusterDown: // slot not currently served + case RedisErrorKind.MasterDown: // replica cannot serve, primary is unavailable + case RedisErrorKind.TryAgain: // slot mid-migration + case RedisErrorKind.Moved: // wrong node; this one did not run it + case RedisErrorKind.Ask: // ditto, mid-migration + case RedisErrorKind.ReadOnly: // writes refused by a replica + case RedisErrorKind.Misconfigured: // e.g. persistence failing, so writes are refused + case RedisErrorKind.NoReplicas: // not enough replicas to accept the write + case RedisErrorKind.Busy: // a script is hogging the server + case RedisErrorKind.MaxClients: // refused at the door + return true; + default: + return false; + } + } } diff --git a/src/StackExchange.Redis/Availability/RetryController.cs b/src/StackExchange.Redis/Availability/RetryController.cs index 72bbf8c335..a47d79bbcc 100644 --- a/src/StackExchange.Redis/Availability/RetryController.cs +++ b/src/StackExchange.Redis/Availability/RetryController.cs @@ -13,7 +13,7 @@ namespace StackExchange.Redis.Availability; /// internal sealed class RetryController { - private readonly int _maxBeforeFailover, _maxAttempts, _delayMillis, _jitterMillis, _failoverMillis; + private readonly int _maxBeforeFailover, _maxAttempts, _delayMillis, _jitterMillis, _failoverMillis, _maxWatchAttempts; private readonly RetryPolicy _policy; public RetryController(RetryPolicy policy, DatabaseFeatureFlags features) @@ -29,9 +29,11 @@ public RetryController(RetryPolicy policy, DatabaseFeatureFlags features) _delayMillis = ToMilliseconds(policy.RetryDelay); _failoverMillis = ToMilliseconds(policy.FailoverDelay); _jitterMillis = ToMilliseconds(policy.JitterMax); + _maxWatchAttempts = policy.MaxAttemptsOnWatchConflict; Debug.Assert(_maxAttempts >= 1 && _maxBeforeFailover >= 1, "attempt counts should be validated by RetryPolicy"); Debug.Assert(_delayMillis >= 0 && _jitterMillis >= 0 && _failoverMillis >= 0, "delays should be validated by RetryPolicy"); + Debug.Assert(_maxWatchAttempts >= 1, "watch-conflict attempts should be validated by RetryPolicy"); static int ToMilliseconds(TimeSpan value) => (int)(value.Ticks / TimeSpan.TicksPerMillisecond); } @@ -41,6 +43,21 @@ public RetryController(RetryPolicy policy, DatabaseFeatureFlags features) /// public RetryPolicy Policy => _policy; + /// + /// How many times a conditional transaction may be attempted when the server keeps rejecting the + /// EXEC due to watch contention; see . + /// + public int MaxWatchConflictAttempts => _maxWatchAttempts; + + /// + /// The pause before re-attempting a transaction that lost a WATCH race. Contention, not a fault: + /// no backoff, just jitter to avoid two callers colliding again in lock-step. + /// + public Task WatchConflictDelayAsync() + => _jitterMillis is 0 + ? Task.CompletedTask + : Task.Delay(ServerSelectionStrategy.SharedRandom.Next(_jitterMillis), CancellationToken.None); + /// /// Whether it is ever worth capturing the next-failover token: only when there is more than one /// attempt and the failover threshold sits below the attempt cap. diff --git a/src/StackExchange.Redis/Availability/RetryDatabase.cs b/src/StackExchange.Redis/Availability/RetryDatabase.cs index 958299db2c..ea037d786f 100644 --- a/src/StackExchange.Redis/Availability/RetryDatabase.cs +++ b/src/StackExchange.Redis/Availability/RetryDatabase.cs @@ -16,6 +16,9 @@ internal partial class RetryDatabase : IDatabaseAsync, IInternalDatabaseAsync DatabaseFeatureFlags IInternalDatabaseAsync.GetFeatures(out string name) => _inner.GetFeatures(out name) | DatabaseFeatureFlags.Retry; + // never: we refuse to wrap a database that carries one (see Validate) + object? IInternalDatabaseAsync.AsyncState => null; + /// public override string ToString() => this.BuildString(); @@ -28,11 +31,25 @@ public CancellationToken GetNextFailover() => _controller.TracksFailover ? _inner.GetNextFailover() : CancellationToken.None; public RetryDatabase(IDatabaseAsync inner, RetryPolicy policy) - // cannot nest retry, and cannot issue retries *inside* a batch/transaction - : this(inner, policy, inner.RejectFlags(DatabaseFeatureFlags.Batch | DatabaseFeatureFlags.Transaction | DatabaseFeatureFlags.Retry)) + : this(inner, policy, Validate(inner)) { } + private static DatabaseFeatureFlags Validate(IDatabaseAsync inner) + { + // cannot nest retry, and cannot issue retries *inside* a batch/transaction + var features = inner.RejectFlags(DatabaseFeatureFlags.Batch | DatabaseFeatureFlags.Transaction | DatabaseFeatureFlags.Retry); + + // async-state is stamped onto the task that a *single* attempt produces; a retrying database hands + // back its own durable task that spans however many attempts it takes, so it cannot preserve the + // state. Refuse rather than dropping it silently. See also CreateTransaction, below. + if (inner.GetAsyncState() is not null) ThrowAsyncState(); + return features; + } + + internal static void ThrowAsyncState() => throw new InvalidOperationException( + "Retrying databases do not support asyncState; the tasks they hand back are not the tasks that were sent to the server."); + // test-only: supply the inner database's feature set directly (in particular whether failover is // available), instead of probing a live inner - so that failover behaviour can be exercised over a // null inner without a full IDatabaseAsync double. @@ -43,8 +60,14 @@ internal RetryDatabase(IDatabaseAsync inner, RetryPolicy policy, DatabaseFeature } ITransactionAsync IDatabaseAsync.CreateTransaction(object? asyncState) + { + // as per the constructor: the per-operation tasks handed out at build time are durable proxies + // that outlive any single attempt, so they cannot carry a per-attempt async-state + if (asyncState is not null) ThrowAsyncState(); + // the inner database creates the "real" (one-shot) transactions we replay against each attempt - => new RetryTransaction(_inner, _controller, asyncState); + return new RetryTransaction(_inner, _controller); + } public int Database => _inner.Database; diff --git a/src/StackExchange.Redis/Availability/RetryPolicy.cs b/src/StackExchange.Redis/Availability/RetryPolicy.cs index ffc2a3142e..ada4dd8912 100644 --- a/src/StackExchange.Redis/Availability/RetryPolicy.cs +++ b/src/StackExchange.Redis/Availability/RetryPolicy.cs @@ -17,6 +17,7 @@ public class RetryPolicy { internal const int DefaultMaxAttempts = 3; internal const int DefaultMaxAttemptsBeforeFailover = 1; + internal const int DefaultMaxAttemptsOnWatchConflict = 3; internal const CommandFlags DefaultMaxCommandRetryCategory = CommandFlags.CommandRetryWriteLastWins; internal static readonly TimeSpan DefaultRetryDelay = TimeSpan.FromSeconds(1); internal static readonly TimeSpan DefaultJitterMax = TimeSpan.FromMilliseconds(500); @@ -40,7 +41,7 @@ public class RetryPolicy /// - use to obtain the standard policy. /// protected RetryPolicy() - : this(DefaultMaxAttempts, DefaultMaxAttemptsBeforeFailover, DefaultRetryDelay, DefaultJitterMax, DefaultFailoverDelay, DefaultMaxCommandRetryCategory) + : this(DefaultMaxAttempts, DefaultMaxAttemptsBeforeFailover, DefaultMaxAttemptsOnWatchConflict, DefaultRetryDelay, DefaultJitterMax, DefaultFailoverDelay, DefaultMaxCommandRetryCategory) { } @@ -52,6 +53,7 @@ protected RetryPolicy(Builder builder) : this( Validate(builder).MaxAttempts, builder.MaxAttemptsBeforeFailover, + builder.MaxAttemptsOnWatchConflict, builder.RetryDelay, builder.JitterMax, builder.FailoverDelay, @@ -62,6 +64,7 @@ protected RetryPolicy(Builder builder) private RetryPolicy( int maxAttempts, int maxAttemptsBeforeFailover, + int maxAttemptsOnWatchConflict, TimeSpan retryDelay, TimeSpan jitterMax, TimeSpan failoverDelay, @@ -69,6 +72,7 @@ private RetryPolicy( { MaxAttempts = maxAttempts; MaxAttemptsBeforeFailover = maxAttemptsBeforeFailover; + MaxAttemptsOnWatchConflict = maxAttemptsOnWatchConflict; RetryDelay = retryDelay; JitterMax = jitterMax; FailoverDelay = failoverDelay; @@ -90,6 +94,23 @@ private RetryPolicy( /// public int MaxAttemptsBeforeFailover { get; } + /// + /// The maximum number of times a *conditional* transaction may be attempted when the only problem is + /// that the server rejected the EXEC because a watched key changed underneath it. Defaults to 3; + /// a value of 1 disables re-attempting such transactions. + /// + /// + /// This is deliberately separate from : a watch conflict is contention, + /// not a fault. Nothing was applied, nothing is broken, and the right response is to re-read the + /// conditions and try again immediately - so no is applied (only + /// ), no failover is attempted, and does + /// not apply. Each re-attempt re-issues the WATCH constraints, so a transaction whose condition + /// has genuinely stopped holding converges on an ordinary elective abort rather than looping. + /// Only transactions with conditions can be affected: without a condition there is no + /// WATCH, so there is nothing to conflict. + /// + public int MaxAttemptsOnWatchConflict { get; } + /// /// Gets the time to wait between retries that are *not* dependent on a failover happening. Defaults to 1 second. /// @@ -120,7 +141,7 @@ private RetryPolicy( public virtual RetryResult CanRetry(in FaultContext fault) { var actual = fault.Flags & Message.MaskRetryCategory; - if (actual is 0) actual = CommandFlags.CommandRetryWriteAccumulating; // if not set, assume similar to INCR + if (actual is 0) actual = CommandFlags.CommandRetryNever; // if not set, assume the worst (as FaultContext does) if (actual is CommandFlags.CommandRetryNever) { @@ -128,7 +149,10 @@ public virtual RetryResult CanRetry(in FaultContext fault) return RetryResult.None; } - if (actual > MaxCommandRetryCategory) // note this also covers CommandRetryAlways + // the category exists to price the *ambiguity* of a replay: if we know the operation never took + // effect, re-issuing it is a first attempt rather than a repeat, so it cannot double-apply and the + // side-effect scale is irrelevant. (CommandRetryNever above is still an absolute veto.) + if (actual > MaxCommandRetryCategory && !fault.NotApplied) { // side-effects are beyond what the policy allows return RetryResult.None; @@ -155,6 +179,11 @@ private static Builder Validate(Builder builder) // values < 1 can never be hit by the attempt counter (which starts at 1), so they would *silently* // disable failover rather than erroring if (builder.MaxAttemptsBeforeFailover < 1) throw new ArgumentOutOfRangeException(nameof(builder.MaxAttemptsBeforeFailover), builder.MaxAttemptsBeforeFailover, "At least one attempt is required before failover."); + + // this one counts *attempts*, so 1 means "try once, do not re-attempt"; zero or negative is + // meaningless rather than a way to say "never execute" + if (builder.MaxAttemptsOnWatchConflict < 1) throw new ArgumentOutOfRangeException(nameof(builder.MaxAttemptsOnWatchConflict), builder.MaxAttemptsOnWatchConflict, "At least one attempt is required; use 1 to disable re-attempting on watch conflicts."); + if (builder.RetryDelay < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(builder.RetryDelay), builder.RetryDelay, "A non-negative retry delay is required."); if (builder.JitterMax < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(builder.JitterMax), builder.JitterMax, "A non-negative jitter bound is required."); if (builder.FailoverDelay < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(builder.FailoverDelay), builder.FailoverDelay, "A non-negative failover delay is required."); @@ -195,6 +224,7 @@ public Builder(RetryPolicy policy) { MaxAttempts = policy.MaxAttempts; MaxAttemptsBeforeFailover = policy.MaxAttemptsBeforeFailover; + MaxAttemptsOnWatchConflict = policy.MaxAttemptsOnWatchConflict; RetryDelay = policy.RetryDelay; JitterMax = policy.JitterMax; FailoverDelay = policy.FailoverDelay; @@ -211,6 +241,9 @@ public Builder(RetryPolicy policy) /// public int MaxAttemptsBeforeFailover { get; set; } = DefaultMaxAttemptsBeforeFailover; + /// + public int MaxAttemptsOnWatchConflict { get; set; } = DefaultMaxAttemptsOnWatchConflict; + /// /// The time to wait between retries that are *not* dependent on a failover happening. /// @@ -241,6 +274,7 @@ public RetryPolicy Create() // prefer the shared default instance when nothing has been customized if (MaxAttempts == DefaultMaxAttempts && MaxAttemptsBeforeFailover == DefaultMaxAttemptsBeforeFailover + && MaxAttemptsOnWatchConflict == DefaultMaxAttemptsOnWatchConflict && RetryDelay == DefaultRetryDelay && JitterMax == DefaultJitterMax && FailoverDelay == DefaultFailoverDelay @@ -249,7 +283,7 @@ public RetryPolicy Create() return DefaultInstance; } - return new RetryPolicy(MaxAttempts, MaxAttemptsBeforeFailover, RetryDelay, JitterMax, FailoverDelay, MaxCommandRetryCategory); + return new RetryPolicy(MaxAttempts, MaxAttemptsBeforeFailover, MaxAttemptsOnWatchConflict, RetryDelay, JitterMax, FailoverDelay, MaxCommandRetryCategory); } /// @@ -261,7 +295,12 @@ public RetryPolicy Create() private sealed class NoRetryPolicy : RetryPolicy { public static readonly NoRetryPolicy Instance = new(); - private NoRetryPolicy() { } + + // "never retries anything" has to include watch contention: that path does not consult CanRetry + // (nothing was applied, so there is no fault to judge), it is bounded purely by the attempt count + private NoRetryPolicy() : base(new Builder { MaxAttemptsOnWatchConflict = 1 }) + { + } public override RetryResult CanRetry(in FaultContext fault) => RetryResult.None; } diff --git a/src/StackExchange.Redis/Availability/RetryTransaction.cs b/src/StackExchange.Redis/Availability/RetryTransaction.cs index c79507bd15..0d87cd47d5 100644 --- a/src/StackExchange.Redis/Availability/RetryTransaction.cs +++ b/src/StackExchange.Redis/Availability/RetryTransaction.cs @@ -19,18 +19,22 @@ internal sealed partial class RetryTransaction : IDatabaseAsync, ITransactionAsy { // Note: async-only, exactly like RetryDatabase - retrying is inherently delay-ish. private readonly IDatabaseAsync _source; - private readonly object? _asyncState; private readonly RetryController _controller; private readonly List _ops = new(); private List? _conditions; private int _executed; + private volatile bool _watchConflict; - public RetryTransaction(IDatabaseAsync source, RetryController controller, object? asyncState) + /// + // reports the *final* attempt's outcome: false if we eventually committed (or aborted electively), + // true if we ran out of watch-conflict attempts still losing the race + public bool WasWatchConflict => _watchConflict; + + public RetryTransaction(IDatabaseAsync source, RetryController controller) { _source = source; _controller = controller; - _asyncState = asyncState; } public int Database => _source.Database; @@ -83,9 +87,14 @@ public async Task ExecuteAsync(CommandFlags flags = CommandFlags.None) CancellationToken failover = _controller.TracksFailover ? _source.GetNextFailover() : CancellationToken.None; var conditions = _conditions; + + // watch contention gets its own budget; only meaningful when there are conditions to WATCH + int watchAttempt = 0; + int maxWatchAttempts = conditions is null ? 1 : _controller.MaxWatchConflictAttempts; while (true) { - var inner = _source.CreateTransaction(_asyncState); + // no async-state: RetryDatabase refuses one (the durable proxies below cannot carry it) + var inner = _source.CreateTransaction(); // replay the recorded constraints and operations onto this fresh, one-shot transaction; the // per-attempt tasks they return are forwarded to the durable proxies only on a clean execution @@ -103,9 +112,25 @@ public async Task ExecuteAsync(CommandFlags flags = CommandFlags.None) try { bool committed = await inner.ExecuteAsync(effectiveFlags).ConfigureAwait(false); + _watchConflict = inner.WasWatchConflict; // surfaced to our own caller, per attempt + + // The server rejected an EXEC we really did issue, because another connection changed a + // watched key: the conditions still held, nothing was applied, and we simply lost a race. + // Re-read and try again (which re-issues the WATCH constraints, so a condition that has + // genuinely stopped holding converges on an elective abort instead of looping). This is + // contention rather than a fault, so it neither consumes the fault budget nor waits for a + // failover, and the side-effect category does not apply - nothing happened. + if (!committed + && _watchConflict + && ++watchAttempt < maxWatchAttempts) + { + foreach (var op in _ops) op.Observe(); + await _controller.WatchConflictDelayAsync().ConfigureAwait(false); + continue; + } - // clean completion (committed, or electively aborted via a failed WATCH); forward the - // per-attempt outcomes onto the durable proxies and we're done + // clean completion (committed, or aborted); forward the per-attempt outcomes onto the + // durable proxies and we're done if (conditions is not null) { foreach (var c in conditions) c.ForwardSuccess(); diff --git a/src/StackExchange.Redis/ExceptionFactory.cs b/src/StackExchange.Redis/ExceptionFactory.cs index 8428d41af8..0f6ab3cb50 100644 --- a/src/StackExchange.Redis/ExceptionFactory.cs +++ b/src/StackExchange.Redis/ExceptionFactory.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Security.Authentication; using System.Text; @@ -164,6 +165,37 @@ internal static Exception NoConnectionAvailable( return ex; } + /// + /// A connection failure produces a single exception describing the *connection*, which is then handed + /// to every message that was in flight. Sharing one instance across many unrelated callers is dubious + /// in itself ( is mutable, so each caller sees the others' additions), and + /// it discards the per-message detail the retry machinery needs: the command's retry category, and + /// whether this particular message had actually been written. Give each message its own. + /// + internal static Exception PerMessage(Exception shared, Message message) + { + // only connection failures get shared like this; anything else already describes one operation + if (shared is not RedisConnectionException conn + || (conn.Flags == message.Flags && conn.CommandStatus == message.Status)) + { + return shared; + } + + var ex = new RedisConnectionException( + conn.FailureType, + message.Flags, + conn.Message, + conn.InnerException, + message.Status); + foreach (DictionaryEntry entry in conn.Data) + { + ex.Data[entry.Key] = entry.Value; + } + ex.Data[DataSentStatusKey] = message.Status; // ...and correct this one for *this* message + if (conn.HelpLink is not null) ex.HelpLink = conn.HelpLink; + return ex; + } + internal static Exception? PopulateInnerExceptions(ReadOnlySpan serverSnapshot) { var innerExceptions = new List(); diff --git a/src/StackExchange.Redis/Interfaces/IInternalDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IInternalDatabaseAsync.cs index 6abc0505ad..6aea5db572 100644 --- a/src/StackExchange.Redis/Interfaces/IInternalDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IInternalDatabaseAsync.cs @@ -21,6 +21,13 @@ internal interface IInternalDatabaseAsync : IDatabaseAsync { DatabaseFeatureFlags GetFeatures(out string name); CancellationToken GetNextFailover(); + + /// + /// The async-state that this database stamps onto the tasks it hands out, if any; wrappers that + /// cannot preserve it (see RetryDatabase) need to know when one is present rather than + /// dropping it silently. + /// + object? AsyncState { get; } } /// @@ -67,6 +74,9 @@ internal static DatabaseFeatureFlags RejectFlags(this IDatabaseAsync database, D $"This operation is not compatible with feature(s): {overlap}"); } + internal static object? GetAsyncState(this IDatabaseAsync database) + => database is IInternalDatabaseAsync ida ? ida.AsyncState : null; + internal static CancellationToken GetNextFailover(this IDatabaseAsync database) { // get a CT that represents the next failover; you might be asking "shouldn't that be a Task getter?" - no, diff --git a/src/StackExchange.Redis/Interfaces/ITransactionAsync.cs b/src/StackExchange.Redis/Interfaces/ITransactionAsync.cs index a9c840dc19..14548c7d70 100644 --- a/src/StackExchange.Redis/Interfaces/ITransactionAsync.cs +++ b/src/StackExchange.Redis/Interfaces/ITransactionAsync.cs @@ -26,4 +26,28 @@ public interface ITransactionAsync : IDatabaseAsync /// /// The command flags to use. Task ExecuteAsync(CommandFlags flags = CommandFlags.None); + + /// + /// Whether the transaction failed to commit because the *server* rejected the EXEC: every + /// condition held, so MULTI/EXEC really was issued, but a key being watched on behalf of + /// those conditions was modified by another connection in the meantime. + /// + /// + /// A transaction that does not commit reports false from Execute for two quite + /// different reasons, and this is what tells them apart: + /// + /// A condition was not satisfied, so the transaction was abandoned without ever + /// issuing an EXEC. The value genuinely was not what was asserted, and re-running would assert + /// the same thing again. This property is false, and the offending + /// is also false. + /// A watched key was changed by a different connection between the + /// conditions being evaluated and the EXEC arriving. This property is true, every + /// is true, and re-reading and trying again is the + /// expected response - this is what the WATCH/MULTI/EXEC idiom is for. + /// + /// Either way nothing was applied, and every queued operation's task is cancelled. This can only + /// be true for a transaction that has conditions (without one there is no WATCH, so there + /// is nothing to conflict over), and is false before the transaction has been executed. + /// + bool WasWatchConflict { get; } } diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs index 306143f9a9..c889a2c571 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs @@ -35,6 +35,9 @@ DatabaseFeatureFlags IInternalDatabaseAsync.GetFeatures(out string name) CancellationToken IInternalDatabaseAsync.GetNextFailover() => Inner.GetNextFailover(); + // this wrapper does not stamp its own async-state; it inherits whatever the inner database uses + object? IInternalDatabaseAsync.AsyncState => Inner.GetAsyncState(); + // the flags contributed by this wrapper itself (on top of the inner database); the batch and // transaction subclasses override to fold in their own flag, mirroring RedisDatabase/RedisBatch/ // RedisTransaction rather than relying on the inner instance to carry it. diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedTransaction.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedTransaction.cs index 78e1a133a6..3658d65ee9 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedTransaction.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedTransaction.cs @@ -15,6 +15,9 @@ private protected override DatabaseFeatureFlags GetDatabaseFeatures() CommandFlags IInternalTransaction.GetAggregateRetryCategory() => Inner is IInternalTransaction it ? it.GetAggregateRetryCategory() : CommandFlags.CommandRetryNever; + /// + public bool WasWatchConflict => Inner.WasWatchConflict; + public ConditionResult AddCondition(Condition condition) => Inner.AddCondition(condition.MapKeys(GetMapFunction())); public bool Execute(CommandFlags flags = CommandFlags.None) => Inner.Execute(flags); diff --git a/src/StackExchange.Redis/PhysicalBridge.cs b/src/StackExchange.Redis/PhysicalBridge.cs index 5bfa67ff03..0498245365 100644 --- a/src/StackExchange.Redis/PhysicalBridge.cs +++ b/src/StackExchange.Redis/PhysicalBridge.cs @@ -515,8 +515,12 @@ private void AbandonPendingBacklog(Exception ex, PhysicalConnection? connection) { while (BacklogTryDequeue(out Message? next)) { - Multiplexer.OnMessageFaulted(next, ex); - next.SetExceptionAndComplete(ex, connection); + // as in PhysicalConnection.RecordMessageFailed: don't hand the same exception to every + // message. A backlogged message has provably never been written, which is exactly what the + // retry machinery needs to know, and it is lost if we share the connection-level instance. + var perMessage = ExceptionFactory.PerMessage(ex, next); + Multiplexer.OnMessageFaulted(next, perMessage); + next.SetExceptionAndComplete(perMessage, connection); } } diff --git a/src/StackExchange.Redis/PhysicalConnection.cs b/src/StackExchange.Redis/PhysicalConnection.cs index 3bfb8faba3..80ef8621b6 100644 --- a/src/StackExchange.Redis/PhysicalConnection.cs +++ b/src/StackExchange.Redis/PhysicalConnection.cs @@ -542,6 +542,10 @@ private void RecordMessageFailed(Message next, Exception? ex, string? origin, Ph } else { + // the connection-level exception is shared across every message being failed here; give this + // one its own, carrying *its* flags and sent-status so retry policy can reason about it + if (ex is not null) ex = ExceptionFactory.PerMessage(ex, next); + var bridge = connection?.BridgeCouldBeNull; if (bridge is not null) { diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 1e48387d6d..427d8c4ad8 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -27,6 +27,7 @@ StackExchange.Redis.IDatabaseAsync.Database.get -> int [SER007]StackExchange.Redis.Availability.RetryPolicy.JitterMax.get -> System.TimeSpan [SER007]StackExchange.Redis.Availability.RetryPolicy.MaxAttempts.get -> int [SER007]StackExchange.Redis.Availability.RetryPolicy.MaxAttemptsBeforeFailover.get -> int +[SER007]StackExchange.Redis.Availability.RetryPolicy.MaxAttemptsOnWatchConflict.get -> int [SER007]StackExchange.Redis.Availability.RetryPolicy.MaxCommandRetryCategory.get -> StackExchange.Redis.CommandFlags [SER007]StackExchange.Redis.Availability.RetryPolicy.RetryDelay.get -> System.TimeSpan [SER007]StackExchange.Redis.Availability.RetryPolicy.RetryPolicy() -> void @@ -37,6 +38,7 @@ StackExchange.Redis.Availability.DatabaseExtensions StackExchange.Redis.ITransactionAsync StackExchange.Redis.ITransactionAsync.AddCondition(StackExchange.Redis.Condition! condition) -> StackExchange.Redis.ConditionResult! StackExchange.Redis.ITransactionAsync.ExecuteAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.ITransactionAsync.WasWatchConflict.get -> bool [SER007]StackExchange.Redis.Availability.CircuitBreaker [SER007]StackExchange.Redis.Availability.CircuitBreaker.Accumulator [SER007]StackExchange.Redis.Availability.CircuitBreaker.Accumulator.Accumulator() -> void @@ -114,6 +116,7 @@ StackExchange.Redis.ITransactionAsync.ExecuteAsync(StackExchange.Redis.CommandFl [SER007]StackExchange.Redis.Availability.FaultContext.FaultContext(System.Exception! fault) -> void [SER007]StackExchange.Redis.Availability.FaultContext.Flags.get -> StackExchange.Redis.CommandFlags [SER007]StackExchange.Redis.Availability.FaultContext.IsFault.get -> bool +[SER007]StackExchange.Redis.Availability.FaultContext.NotApplied.get -> bool [SER007]StackExchange.Redis.RedisErrorKind.Ask = 15 -> StackExchange.Redis.RedisErrorKind [SER007]StackExchange.Redis.RedisErrorKind.Busy = 12 -> StackExchange.Redis.RedisErrorKind [SER007]StackExchange.Redis.RedisErrorKind.ClusterDown = 6 -> StackExchange.Redis.RedisErrorKind @@ -239,6 +242,8 @@ StackExchange.Redis.RedisTimeoutException.RedisTimeoutException(StackExchange.Re [SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.JitterMax.set -> void [SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.MaxAttemptsBeforeFailover.get -> int [SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.MaxAttemptsBeforeFailover.set -> void +[SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.MaxAttemptsOnWatchConflict.get -> int +[SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.MaxAttemptsOnWatchConflict.set -> void [SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.MaxAttempts.get -> int [SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.MaxAttempts.set -> void [SER007]StackExchange.Redis.Availability.RetryPolicy.Builder.MaxCommandRetryCategory.get -> StackExchange.Redis.CommandFlags diff --git a/src/StackExchange.Redis/RedisTransaction.cs b/src/StackExchange.Redis/RedisTransaction.cs index 0fc790440f..b23ade3d5e 100644 --- a/src/StackExchange.Redis/RedisTransaction.cs +++ b/src/StackExchange.Redis/RedisTransaction.cs @@ -13,8 +13,13 @@ internal sealed class RedisTransaction : RedisDatabase, ITransaction, IInternalT { private List? _conditions; private List? _pending; + private TransactionMessage? _lastMessage; private object SyncLock => this; + /// + // set by TransactionProcessor when the server answers EXEC with a null array + public bool WasWatchConflict => _lastMessage?.WasWatchConflict == true; + // combine the retry categories of all queued operations, taking the most side-effecting (numerically // highest) - this is what a replay of the whole transaction would do. WATCH constraints are *not* // included: they live in _conditions, not _pending, and re-issuing them is what makes replay safe. @@ -195,7 +200,7 @@ private void QueueMessage(Message message) } processor = TransactionProcessor.Default; - return new TransactionMessage(Database, flags, cond, work); + return _lastMessage = new TransactionMessage(Database, flags, cond, work); } private sealed class QueuedMessage : Message @@ -279,6 +284,16 @@ internal override void SetExceptionAndComplete(Exception exception, PhysicalConn public bool IsAborted => command != RedisCommand.EXEC; + // the server rejected an EXEC we really did issue, because a watched key moved; volatile + // because it is written on the read loop and observed by whoever awaited the transaction + public bool WasWatchConflict + { + get => Volatile.Read(ref _watchConflict); + set => Volatile.Write(ref _watchConflict, value); + } + + private bool _watchConflict; + public override void AppendStormLog(StringBuilder sb) { base.AppendStormLog(sb); @@ -528,16 +543,23 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes if (reader.IsNull) // EXEC returned with a NULL { - if (tran.IsAborted) + // the server refused to apply the transaction because a watched key changed + // ("WATCH drift"); nothing was applied, so every queued operation must be + // brought to a terminal state - otherwise the caller's tasks hang forever. + // (in the electively-aborted case they were already cancelled in GetMessages; + // Cancel/Complete are idempotent, so doing it again is harmless) + muxer?.OnTransactionLog("Aborting wrapped messages (failed watch)"); + connection.Trace("Server aborted due to failed WATCH"); + + // distinguish "the server refused an EXEC we issued" from "we chose not to issue + // one"; only the former is worth re-attempting (see IInternalTransaction) + tran.WasWatchConflict = !tran.IsAborted; + + foreach (var op in wrapped) { - muxer?.OnTransactionLog("Aborting wrapped messages (failed watch)"); - connection.Trace("Server aborted due to failed WATCH"); - foreach (var op in wrapped) - { - var inner = op.Wrapped; - inner.Cancel(); - inner.Complete(connection); - } + var inner = op.Wrapped; + inner.Cancel(); + inner.Complete(connection); } SetResult(message, false); return true; diff --git a/tests/StackExchange.Redis.Tests/InProcessTestServer.cs b/tests/StackExchange.Redis.Tests/InProcessTestServer.cs index 2b14692beb..2f6833ace7 100644 --- a/tests/StackExchange.Redis.Tests/InProcessTestServer.cs +++ b/tests/StackExchange.Redis.Tests/InProcessTestServer.cs @@ -403,13 +403,15 @@ protected override void Dispose(bool disposing) if (disposing) _server.Dispose(); } */ - public void SetLatency(TimeSpan latency) => _latency = latency; + // written by the test thread, read by the server's own thread(s): store as ticks so the read/write can + // be made explicitly visible (a plain TimeSpan field can be missed indefinitely) + public void SetLatency(TimeSpan latency) => Volatile.Write(ref _latencyTicks, latency.Ticks); - private TimeSpan _latency = TimeSpan.Zero; + private long _latencyTicks; protected override ValueTask ClientPauseAsync(RedisClient client, in RedisRequest request) { - var latency = _latency; + var latency = TimeSpan.FromTicks(Volatile.Read(ref _latencyTicks)); if (latency > TimeSpan.Zero & request.KnownCommand != RedisCommand.QUIT) { Log($"[{client}] holding {request.Command} response by {latency.TotalMilliseconds}ms"); diff --git a/tests/StackExchange.Redis.Tests/RetryTests/CommandRetryPolicyUnitTests.cs b/tests/StackExchange.Redis.Tests/RetryTests/CommandRetryPolicyUnitTests.cs index 45720df2ed..c1d6e1d513 100644 --- a/tests/StackExchange.Redis.Tests/RetryTests/CommandRetryPolicyUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/RetryTests/CommandRetryPolicyUnitTests.cs @@ -18,10 +18,18 @@ private static RetryResult CanRetry(RedisErrorKind kind, CommandFlags flags, Ret return (policy ?? RetryPolicy.Default).CanRetry(in fault); } + // As above, but for an *ambiguous* fault: a timeout on a request we know was sent. We have no idea + // whether the server applied it, so this is the case the retry-category exists to price. + private static RetryResult CanRetryAmbiguous(CommandFlags flags, RetryPolicy? policy = null) + { + var fault = new FaultContext(new RedisTimeoutException(flags, "timeout", CommandStatus.Sent)); + return (policy ?? RetryPolicy.Default).CanRetry(in fault); + } + // The command's retry-category is checked against the policy's max category: the default max is // CommandRetryWriteLastWins, so anything at-or-below that is in-range, and anything with more - // side-effects is not. Using a transient LOADING fault so the error-kind check permits a retry - // whenever the category is in-range - isolating the category logic. + // side-effects is not. Using an ambiguous (timeout-after-send) fault, since that is where the + // category actually bites - see CanRetry_NotAppliedBypassesCategory for the other half. [Theory] [InlineData(CommandFlags.CommandRetryAlways, true)] [InlineData(CommandFlags.CommandRetryConnection, true)] @@ -31,13 +39,51 @@ private static RetryResult CanRetry(RedisErrorKind kind, CommandFlags flags, Ret [InlineData(CommandFlags.CommandRetryWriteAccumulating, false)] // beyond default max [InlineData(CommandFlags.CommandRetryServerAdmin, false)] [InlineData(CommandFlags.CommandRetryNever, false)] - [InlineData(CommandFlags.None, false)] // unspecified => assume worst (accumulating) => beyond default max + [InlineData(CommandFlags.None, false)] // unspecified => assume the worst => not retried public void CanRetry_CategoryVersusDefaultMax(CommandFlags category, bool expectRetry) { + var result = CanRetryAmbiguous(category); + Assert.Equal(expectRetry, result != RetryResult.None); + } + + // When the fault proves the operation never took effect (here: the server was still LOADING, so it + // rejected the command outright), a replay is a first attempt rather than a repeat - it cannot + // double-apply anything, so the side-effect category is irrelevant and the cap is bypassed. An + // explicit CommandRetryNever is still an absolute veto, as is an unspecified category. + [Theory] + [InlineData(CommandFlags.CommandRetryWriteAccumulating, true)] // beyond the default cap, but safe here + [InlineData(CommandFlags.CommandRetryServerAdmin, true)] + [InlineData(CommandFlags.CommandRetryNever, false)] // never means never + [InlineData(CommandFlags.None, false)] // we don't know what it is; don't guess + public void CanRetry_NotAppliedBypassesCategory(CommandFlags category, bool expectRetry) + { + // sanity: the same category against an ambiguous fault of the same "retryability" is refused + if (expectRetry) Assert.Equal(RetryResult.None, CanRetryAmbiguous(category)); + var result = CanRetry(RedisErrorKind.Loading, category); Assert.Equal(expectRetry, result != RetryResult.None); } + // The other source of certainty: the client knows it never wrote the message. Same conclusion, even + // though the fault itself (a connection failure) is otherwise ambiguous. + [Theory] + [InlineData(CommandStatus.WaitingToBeSent, true)] + [InlineData(CommandStatus.WaitingInBacklog, true)] + [InlineData(CommandStatus.Sent, false)] // may or may not have been applied + [InlineData(CommandStatus.Unknown, false)] + public void CanRetry_UnsentMessageBypassesCategory(CommandStatus status, bool expectRetry) + { + var fault = new FaultContext(new RedisConnectionException( + ConnectionFailureType.SocketFailure, + CommandFlags.CommandRetryWriteAccumulating, // beyond the default cap + "boom", + innerException: null, + commandStatus: status)); + + Assert.Equal(expectRetry, fault.NotApplied); + Assert.Equal(expectRetry, RetryPolicy.Default.CanRetry(in fault) != RetryResult.None); + } + // With an in-range category (== default max), the outcome is decided purely by whether the error // is transient: LOADING is worth retrying, WRONGTYPE is an application error that will not fix itself. [Theory] @@ -86,8 +132,8 @@ public void CanRetry_ServerSpecificRestrictsToSameServer(CommandFlags extra, Ret [InlineData(CommandFlags.CommandRetryServerAdmin, false)] // beyond default max public void CanRetry_ServerSpecificDoesNotAffectRange(CommandFlags category, bool expectRetry) { - var withoutFlag = CanRetry(RedisErrorKind.Loading, category); - var withFlag = CanRetry(RedisErrorKind.Loading, category | Message.CommandServerSpecific); + var withoutFlag = CanRetryAmbiguous(category); + var withFlag = CanRetryAmbiguous(category | Message.CommandServerSpecific); Assert.Equal(expectRetry, withoutFlag != RetryResult.None); Assert.Equal(expectRetry, withFlag != RetryResult.None); diff --git a/tests/StackExchange.Redis.Tests/RetryTests/ConnectionFaultDetailTests.cs b/tests/StackExchange.Redis.Tests/RetryTests/ConnectionFaultDetailTests.cs new file mode 100644 index 0000000000..95781c82c4 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RetryTests/ConnectionFaultDetailTests.cs @@ -0,0 +1,153 @@ +using StackExchange.Redis.Availability; +using Xunit; + +namespace StackExchange.Redis.Tests.RetryTests; + +/// +/// When a connection dies, one exception is built to describe the *connection*, and it used to be handed +/// verbatim to every message that was in flight. Sharing one exception instance across unrelated callers is +/// dubious in itself (Exception.Data is mutable), and it discards the per-message facts the retry +/// machinery needs: the command's retry category, and whether this particular message had actually been +/// written. Without them nothing at all is retryable after a connection failure, not even a plain +/// GET. is what splits them apart. +/// +public class ConnectionFaultDetailTests +{ + private static RedisConnectionException SharedConnectionFault() + { + // as built by PhysicalConnection.RecordConnectionFailed: describes the connection, knows nothing + // about any individual message + var ex = new RedisConnectionException( + ConnectionFailureType.SocketClosed, + CommandFlags.None, + "SocketClosed on 127.0.0.1:6379/Interactive"); + ex.Data["Redis-Version"] = "1.2.3"; + ex.Data["Redis-Server"] = "127.0.0.1:6379"; + return ex; + } + + private static Message Read() => Message.Create(0, CommandFlags.None, RedisCommand.GET, (RedisKey)"key"); + + private static Message AccumulatingWrite() => Message.Create(0, CommandFlags.None, RedisCommand.INCR, (RedisKey)"key"); + + // A message that was written before the socket died: the outcome is genuinely ambiguous, so the sent + // status must survive as-is, but the command's category has to come through - otherwise the policy sees + // "no category" and refuses to retry even a pure read. + [Fact] + public void SentMessage_CarriesCategoryAndSentStatus() + { + var shared = SharedConnectionFault(); + var message = Read(); + message.SetRequestSent(); + + var per = Assert.IsType(ExceptionFactory.PerMessage(shared, message)); + + Assert.NotSame(shared, per); + Assert.Equal(ConnectionFailureType.SocketClosed, per.FailureType); + Assert.Equal(CommandStatus.Sent, per.CommandStatus); + Assert.Equal(CommandFlags.CommandRetryReadOnly, per.Flags & Message.MaskRetryCategory); + + var ctx = new FaultContext(per); + Assert.False(ctx.NotApplied); // it was on the wire; we cannot know whether the server ran it + Assert.NotEqual(RetryResult.None, RetryPolicy.Default.CanRetry(in ctx)); // ...but a read is safe + + // for contrast: the shared exception the message used to receive is retryable for nothing at all + var sharedCtx = new FaultContext(shared); + Assert.Equal(RetryResult.None, RetryPolicy.Default.CanRetry(in sharedCtx)); + } + + // Same situation, accumulating write: the category comes through and correctly *blocks* the retry, since + // a replay could double-apply. The caller can still opt in by raising the cap. + [Fact] + public void SentAccumulatingWrite_RemainsGatedByCategory() + { + var message = AccumulatingWrite(); + message.SetRequestSent(); + + var per = Assert.IsType(ExceptionFactory.PerMessage(SharedConnectionFault(), message)); + Assert.Equal(CommandFlags.CommandRetryWriteAccumulating, per.Flags & Message.MaskRetryCategory); + + var ctx = new FaultContext(per); + Assert.False(ctx.NotApplied); + Assert.Equal(RetryResult.None, RetryPolicy.Default.CanRetry(in ctx)); + + RetryPolicy permissive = new RetryPolicy.Builder { MaxCommandRetryCategory = CommandFlags.CommandRetryWriteAccumulating }; + Assert.NotEqual(RetryResult.None, permissive.CanRetry(in ctx)); + } + + // A message that never left the client - still waiting to be written, or sitting in the backlog - is + // *provably* unapplied, which is the one case where even an accumulating write can be safely re-issued. + // That fact lives on the message, so sharing the connection's exception threw it away. + [Theory] + [InlineData(false)] // never handed to the bridge + [InlineData(true)] // queued in the backlog awaiting a healthy connection + public void UnsentMessage_IsKnownNotApplied(bool backlogged) + { + var message = AccumulatingWrite(); + if (backlogged) message.SetBacklogged(); + + var per = Assert.IsType(ExceptionFactory.PerMessage(SharedConnectionFault(), message)); + + Assert.Equal(backlogged ? CommandStatus.WaitingInBacklog : CommandStatus.WaitingToBeSent, per.CommandStatus); + + var ctx = new FaultContext(per); + Assert.True(ctx.NotApplied); + // accumulating, i.e. beyond the default cap - but nothing was applied, so there is nothing to repeat + Assert.NotEqual(RetryResult.None, RetryPolicy.Default.CanRetry(in ctx)); + } + + // The connection-level diagnostics are the useful part of these exceptions, so they have to come across; + // but the dictionaries must be independent, or one caller's annotations show up on another's exception. + [Fact] + public void SharedDiagnosticsAreCopied_ButNotShared() + { + var shared = SharedConnectionFault(); + var first = ExceptionFactory.PerMessage(shared, Read()); + var second = ExceptionFactory.PerMessage(shared, AccumulatingWrite()); + + Assert.NotSame(first, second); + Assert.Equal(shared.Message, first.Message); + Assert.Equal("1.2.3", first.Data["Redis-Version"]); + Assert.Equal("1.2.3", second.Data["Redis-Version"]); + + first.Data["mine"] = "only-mine"; + Assert.False(second.Data.Contains("mine")); + Assert.False(shared.Data.Contains("mine")); + } + + // The per-message status is recorded in the diagnostic data too, so a user reading the exception's Data + // sees this message's status rather than whatever the connection-level exception happened to say. + [Fact] + public void SentStatusIsRecordedInDiagnosticData() + { + var shared = SharedConnectionFault(); + shared.Data["request-sent-status"] = CommandStatus.Unknown; + + var message = Read(); + message.SetRequestSent(); + var per = ExceptionFactory.PerMessage(shared, message); + + Assert.Equal(CommandStatus.Sent, per.Data["request-sent-status"]); + } + + // Only the shared connection-failure shape needs splitting; anything else already describes a single + // operation, and an exception that already matches the message is passed straight through (no needless + // allocation on a teardown that may be failing thousands of messages). + [Fact] + public void UnrelatedOrAlreadyMatchingExceptions_ArePassedThrough() + { + var message = Read(); + message.SetRequestSent(); + + var serverFault = new RedisServerException(RedisErrorKind.Loading, message.Flags, "LOADING"); + Assert.Same(serverFault, ExceptionFactory.PerMessage(serverFault, message)); + + var alreadySpecific = new RedisConnectionException( + ConnectionFailureType.SocketClosed, + message.Flags, + "already describes this message", + innerException: null, + commandStatus: CommandStatus.Sent); + Assert.Same(alreadySpecific, ExceptionFactory.PerMessage(alreadySpecific, message)); + } +} diff --git a/tests/StackExchange.Redis.Tests/RetryTests/RetryControllerTests.cs b/tests/StackExchange.Redis.Tests/RetryTests/RetryControllerTests.cs new file mode 100644 index 0000000000..313d40c30a --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RetryTests/RetryControllerTests.cs @@ -0,0 +1,206 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using StackExchange.Redis.Availability; +using StackExchange.Redis.Interfaces; +using Xunit; + +namespace StackExchange.Redis.Tests.RetryTests; + +// Configuration validation (which lives on RetryPolicy.Builder, since a RetryPolicy is immutable and +// validated on construction) and the wait/failover timing state machine of RetryController; neither needs a +// server, or even an inner database - CanRetry and the delays never touch one. +public class RetryControllerTests +{ + // A failover threshold below 1 could never be reached by the attempt counter (which starts at 1), so + // it would *silently* disable failover; that is rejected up front. + [Fact] + public void Policy_RejectsUnreachableFailoverThreshold() + => Assert.Throws(() => new RetryPolicy.Builder { MaxAttemptsBeforeFailover = 0 }.Create()); + + // DatabaseFeatureFlags is internal, so theories take a bool and map here + private static DatabaseFeatureFlags Features(bool withFailover) + => withFailover ? DatabaseFeatureFlags.Failover : DatabaseFeatureFlags.None; + + // Negative durations are nonsense for a delay; each is validated separately. + [Fact] + public void Policy_RejectsNegativeDurations() + { + var negative = TimeSpan.FromMilliseconds(-1); + Assert.Throws(() => new RetryPolicy.Builder { RetryDelay = negative }.Create()); + Assert.Throws(() => new RetryPolicy.Builder { JitterMax = negative }.Create()); + Assert.Throws(() => new RetryPolicy.Builder { FailoverDelay = negative }.Create()); + } + + // The watch-contention budget counts *attempts*, so 1 means "try once, do not re-attempt"; zero or + // negative is meaningless rather than a way to say "never execute". + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Policy_RejectsNonPositiveWatchAttempts(int attempts) + => Assert.Throws(() => new RetryPolicy.Builder { MaxAttemptsOnWatchConflict = attempts }.Create()); + + // ...and 1 is accepted, since that is how re-attempting is switched off + [Fact] + public void Policy_AcceptsSingleWatchAttempt() + => Assert.Equal(1, new RetryPolicy.Builder { MaxAttemptsOnWatchConflict = 1 }.Create().MaxAttemptsOnWatchConflict); + + // The category cap must name exactly one of the CommandRetry* values: an empty value, or one that + // strays outside the category bits, is a usage error rather than something to interpret. + [Fact] + public void Policy_RejectsNonCategoryMaxCommandRetryCategory() + { + Assert.Throws(() => new RetryPolicy.Builder { MaxCommandRetryCategory = CommandFlags.None }.Create()); + Assert.Throws(() => new RetryPolicy.Builder { MaxCommandRetryCategory = CommandFlags.FireAndForget }.Create()); + Assert.Throws( + () => new RetryPolicy.Builder { MaxCommandRetryCategory = CommandFlags.CommandRetryReadOnly | CommandFlags.FireAndForget }.Create()); + + RetryPolicy valid = new RetryPolicy.Builder { MaxCommandRetryCategory = CommandFlags.CommandRetryAlways }; + Assert.Equal(CommandFlags.CommandRetryAlways, valid.MaxCommandRetryCategory); + } + + // RetryPolicy.None means *nothing* is re-attempted - including watch contention, which is bounded by + // an attempt count rather than by CanRetry (nothing was applied, so there is no fault to judge). + [Fact] + public void NonePolicy_DisablesWatchReattempts() + { + Assert.Equal(1, RetryPolicy.None.MaxAttemptsOnWatchConflict); + Assert.Equal(DefaultMaxAttemptsOnWatchConflict, RetryPolicy.Default.MaxAttemptsOnWatchConflict); + } + + private const int DefaultMaxAttemptsOnWatchConflict = 3; + + // Round-tripping a policy through a builder must preserve the watch budget along with everything else. + [Fact] + public void Policy_RoundTripsThroughBuilder() + { + RetryPolicy original = new RetryPolicy.Builder { MaxAttemptsOnWatchConflict = 7, MaxAttempts = 4 }; + var copy = new RetryPolicy.Builder(original).Create(); + + Assert.Equal(7, copy.MaxAttemptsOnWatchConflict); + Assert.Equal(4, copy.MaxAttempts); + } + + // Contention is not a fault, so there is no backoff - only jitter, to stop two callers colliding again + // in lock-step. With jitter disabled the re-attempt is immediate. + [Fact] + public async Task WatchConflictDelay_HasNoBackoff() + { + var controller = new RetryController( + new RetryPolicy.Builder + { + RetryDelay = TimeSpan.FromMilliseconds(LongMillis), + FailoverDelay = TimeSpan.FromMilliseconds(LongMillis), + JitterMax = TimeSpan.Zero, + }, + DatabaseFeatureFlags.Failover); + + var watch = Stopwatch.StartNew(); + await controller.WatchConflictDelayAsync(); + Assert.True(watch.ElapsedMilliseconds < ShortMillis, $"returned after {watch.ElapsedMilliseconds}ms"); + } + + // Capturing the "next failover" token costs something, so we only do it when a failover could + // actually be waited on: the database must offer failover, there must be more than one attempt, and + // the threshold must sit strictly below the attempt cap (at the cap it can never be reached). + [Theory] + [InlineData(3, 1, true, true)] + [InlineData(3, 1, false, false)] // no failover available + [InlineData(1, 1, true, false)] // single attempt: nothing to retry + [InlineData(3, 3, true, false)] // threshold == cap: unreachable + [InlineData(3, 4, true, false)] // threshold beyond cap: unreachable + public void TracksFailover_OnlyWhenReachable(int maxAttempts, int beforeFailover, bool withFailover, bool expected) + { + RetryPolicy policy = new RetryPolicy.Builder { MaxAttempts = maxAttempts, MaxAttemptsBeforeFailover = beforeFailover }; + Assert.Equal(expected, new RetryController(policy, Features(withFailover)).TracksFailover); + } + + // MaxAttempts = 1 means "try once": the very first failure is already exhausted. + [Fact] + public void SingleAttempt_NeverRetries() + { + var controller = new RetryController(new RetryPolicy.Builder { MaxAttempts = 1 }, DatabaseFeatureFlags.Failover); + using var cts = new CancellationTokenSource(); + var failover = cts.Token; + var fault = new RedisServerException(RedisErrorKind.Loading, CommandFlags.CommandRetryReadOnly, "LOADING"); + + Assert.False(controller.CanRetry(1, fault, ref failover, out var delay)); + Assert.False(delay.CanBeCanceled); + } + + // --- FailoverOrDelayAsync ----------------------------------------------------------------------- + // Deliberately coarse thresholds: we are distinguishing "waited for the configured period" from + // "returned as soon as it could", not measuring the clock. + private const int LongMillis = 2000, ShortMillis = 1000; + + // No failover token: this is a routine pause between same-server attempts, so it waits RetryDelay. + [Fact] + public async Task Delay_WithoutFailoverToken_WaitsRetryDelay() + { + var controller = new RetryController( + new RetryPolicy.Builder { RetryDelay = TimeSpan.FromMilliseconds(LongMillis), JitterMax = TimeSpan.Zero }, + DatabaseFeatureFlags.None); + + var watch = Stopwatch.StartNew(); + await controller.FailoverOrDelayAsync(CancellationToken.None); + Assert.True(watch.ElapsedMilliseconds >= ShortMillis, $"returned after {watch.ElapsedMilliseconds}ms"); + } + + // A failover token that has *already* fired: there is nothing to wait for, so only jitter applies - + // and in particular RetryDelay is deliberately ignored on the failover path. + [Fact] + public async Task Delay_WithFiredFailoverToken_ReturnsImmediately() + { + var controller = new RetryController( + new RetryPolicy.Builder + { + RetryDelay = TimeSpan.FromMilliseconds(LongMillis), + FailoverDelay = TimeSpan.FromMilliseconds(LongMillis), + JitterMax = TimeSpan.Zero, + }, + DatabaseFeatureFlags.Failover); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); // Cancel, not CancelAsync: this project also targets net481 + + var watch = Stopwatch.StartNew(); + await controller.FailoverOrDelayAsync(cts.Token); + Assert.True(watch.ElapsedMilliseconds < ShortMillis, $"returned after {watch.ElapsedMilliseconds}ms"); + } + + // A failover that arrives while we are waiting: we stop waiting as soon as it lands, rather than + // sitting out the whole FailoverDelay. + [Fact] + public async Task Delay_WhenFailoverArrives_StopsWaiting() + { + var controller = new RetryController( + new RetryPolicy.Builder { FailoverDelay = TimeSpan.FromMilliseconds(LongMillis * 4), JitterMax = TimeSpan.Zero }, + DatabaseFeatureFlags.Failover); + + using var cts = new CancellationTokenSource(); + var watch = Stopwatch.StartNew(); + var pending = controller.FailoverOrDelayAsync(cts.Token); + cts.Cancel(); // Cancel, not CancelAsync: this project also targets net481 + await pending; + + Assert.True(watch.ElapsedMilliseconds < ShortMillis, $"returned after {watch.ElapsedMilliseconds}ms"); + } + + // A failover that never arrives: we give it FailoverDelay and then proceed anyway (retrying on the + // original server is better than giving up). + [Fact] + public async Task Delay_WhenFailoverNeverArrives_ProceedsAfterFailoverDelay() + { + var controller = new RetryController( + new RetryPolicy.Builder { FailoverDelay = TimeSpan.FromMilliseconds(LongMillis), JitterMax = TimeSpan.Zero }, + DatabaseFeatureFlags.Failover); + + using var cts = new CancellationTokenSource(); + var watch = Stopwatch.StartNew(); + await controller.FailoverOrDelayAsync(cts.Token); + + Assert.True(watch.ElapsedMilliseconds >= ShortMillis, $"returned after {watch.ElapsedMilliseconds}ms"); + Assert.False(cts.IsCancellationRequested); // no failover ever happened + } +} diff --git a/tests/StackExchange.Redis.Tests/RetryTests/RetryEndToEndTests.cs b/tests/StackExchange.Redis.Tests/RetryTests/RetryEndToEndTests.cs index 414823af0c..8f2b6b6235 100644 --- a/tests/StackExchange.Redis.Tests/RetryTests/RetryEndToEndTests.cs +++ b/tests/StackExchange.Redis.Tests/RetryTests/RetryEndToEndTests.cs @@ -1,5 +1,6 @@ using System; using System.Net; +using System.Threading; using System.Threading.Tasks; using StackExchange.Redis.Availability; using StackExchange.Redis.Server; @@ -45,6 +46,99 @@ public async Task WithRetry_RidesOutTransientLoading() Assert.Equal(3, server.GetOpsReceived); // 2 x LOADING + 1 x success } + // Retries are bounded: once MaxAttempts is used up the original server fault is surfaced to the + // caller unchanged (not wrapped, not swallowed), and the server saw exactly MaxAttempts requests. + [Fact] + public async Task WithRetry_WhenAttemptsExhausted_ThrowsOriginalFault() + { + using var server = new LoadingServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + + var db = conn.GetDatabase(); + RedisKey key = "retry:exhaust"; + Assert.True(await db.StringSetAsync(key, "hello")); + + server.LoadingOps = 100; // more than we will ever attempt + + RetryPolicy policy = new RetryPolicy.Builder { MaxAttempts = 3, RetryDelay = TimeSpan.Zero, JitterMax = TimeSpan.Zero }; + var ex = await Assert.ThrowsAsync(async () => await db.WithRetry(policy).StringGetAsync(key)); + + Assert.Equal(RedisErrorKind.Loading, ex.Kind); + Assert.Equal(3, server.GetOpsReceived); // tried exactly MaxAttempts times + } + + // A fault that will not fix itself is not worth repeating: WRONGTYPE is an application error, so it + // surfaces on the first attempt regardless of how many attempts the policy allows. + [Fact] + public async Task WithRetry_NonTransientFault_IsNotRetried() + { + using var server = new LoadingServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + + var db = conn.GetDatabase(); + RedisKey key = "retry:wrongtype"; + Assert.True(await db.StringSetAsync(key, "hello")); + + server.LoadingOps = 100; + server.ErrorText = "WRONGTYPE Operation against a key holding the wrong kind of value"; + + RetryPolicy policy = new RetryPolicy.Builder { MaxAttempts = 3, RetryDelay = TimeSpan.Zero, JitterMax = TimeSpan.Zero }; + var ex = await Assert.ThrowsAsync(async () => await db.WithRetry(policy).StringGetAsync(key)); + + Assert.Equal(RedisErrorKind.WrongType, ex.Kind); + Assert.Equal(1, server.GetOpsReceived); // gave up immediately + } + + // An ad-hoc command whose *name* is recognised gets that command's category for free, so a plain + // Execute("get", ...) is retried like any other read - the caller does not have to say anything. + [Fact] + public async Task WithRetry_AdHocCommand_InheritsKnownCommandCategory() + { + using var server = new LoadingServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + + var db = conn.GetDatabase(); + RedisKey key = "retry:adhoc:known"; + Assert.True(await db.StringSetAsync(key, "hello")); + + server.LoadingOps = 2; + + RetryPolicy policy = new RetryPolicy.Builder { MaxAttempts = 3, RetryDelay = TimeSpan.Zero, JitterMax = TimeSpan.Zero }; + var result = await db.WithRetry(policy).ExecuteAsync("get", [key]); + + Assert.Equal("hello", result.AsString()); + Assert.Equal(3, server.GetOpsReceived); // recognised as GET, i.e. read-only, so retried + } + + // A command the library does *not* recognise could do anything, so it is treated pessimistically and + // never retried; a caller who knows better can say so via the flags. + [Theory] + [InlineData(CommandFlags.None, 1)] // unrecognised: assume the worst, do not retry + [InlineData(CommandFlags.CommandRetryReadOnly, 3)] // caller asserts it is a pure read + public async Task WithRetry_UnrecognisedCommand_RespectsSuppliedCategory(CommandFlags flags, int expectedAttempts) + { + using var server = new LoadingServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + + var db = conn.GetDatabase(); + server.LoadingOps = 2; // the third attempt would succeed, if we are allowed a third + + RetryPolicy policy = new RetryPolicy.Builder { MaxAttempts = 3, RetryDelay = TimeSpan.Zero, JitterMax = TimeSpan.Zero }; + var retryDb = db.WithRetry(policy); + + if (expectedAttempts == 1) + { + await Assert.ThrowsAsync(async () => await retryDb.ExecuteAsync("notarealcommand", [], flags)); + } + else + { + var result = await retryDb.ExecuteAsync("notarealcommand", [], flags); + Assert.Equal("made-up-ok", result.AsString()); + } + + Assert.Equal(expectedAttempts, server.UnknownOpsReceived); + } + // Multi-group + WithRetry: two backends hold *different* values for the same key. The group is weighted // towards A, so a normal read returns A's value. When A drops into LOADING, the faults trip A's // (deliberately hair-trigger) circuit-breaker; the group reroutes to B, and the retry wrapper rides that @@ -125,6 +219,62 @@ public async Task WithRetry_FailsOverBetweenGroupsOnLoading() Assert.Same(members[1], conn.ActiveMember); // we really did move to B } + // The unhappy path of the failover threshold: the attempt count says "wait for a failover now", but no + // failover ever comes (the member stays nominally healthy - a couple of LOADING replies are not enough + // to trip the default breaker). We wait out FailoverDelay and then carry on retrying the original + // member rather than giving up, and the group never moves. + [Fact] + public async Task WithRetry_WhenFailoverNeverArrives_KeepsRetryingSameMember() + { + EndPoint alpha = new DnsEndPoint("alpha", 6379); + EndPoint bravo = new DnsEndPoint("bravo", 6379); + using var serverA = new LoadingServer(Output, endpoint: alpha); + using var serverB = new InProcessTestServer(Output, endpoint: bravo); + + RedisKey key = "retry:nofailover"; + await using (var seedA = await serverA.ConnectAsync()) + { + Assert.True(await seedA.GetDatabase().StringSetAsync(key, "from-A")); + } + + ConnectionGroupMember[] members = + [ + new(serverA.GetClientConfig(), "A") { Weight = 9 }, // highest weight -> active, and stays active + new(serverB.GetClientConfig(), "B") { Weight = 1 }, + ]; + + MultiGroupOptions options = new MultiGroupOptions.Builder + { + HealthCheckInterval = TimeSpan.FromMinutes(30), + HealthCheck = new HealthCheck.Builder + { + Probe = new ControllableProbe(), // never marked down + ProbeCount = 1, + ProbeTimeout = TimeSpan.FromSeconds(5), + }, + }; + + await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members, options); + Assert.Same(members[0], conn.ActiveMember); + + // failover is armed after the first attempt, but nothing will ever trigger it; keep the wait short + RetryPolicy policy = new RetryPolicy.Builder + { + MaxAttempts = 5, + MaxAttemptsBeforeFailover = 1, + FailoverDelay = TimeSpan.FromMilliseconds(200), + RetryDelay = TimeSpan.Zero, + JitterMax = TimeSpan.Zero, + }; + var db = conn.GetDatabase().WithRetry(policy); + + serverA.LoadingOps = 2; // two transient faults, then A answers normally + + Assert.Equal("from-A", await db.StringGetAsync(key)); + Assert.Equal(3, serverA.GetOpsReceived); // 2 x LOADING + 1 x success, all on A + Assert.Same(members[0], conn.ActiveMember); // never moved to B + } + // End-to-end for a retryable transaction: a server that fails the first EXEC with a transient LOADING // error (discarding that attempt's queued commands), then commits the second. A transaction created via // the retrying database should ride this out: the per-operation tasks handed out at build time resolve @@ -164,13 +314,14 @@ public async Task WithRetry_Transaction_RidesOutTransientExec() Assert.Equal(2, server.ExecOpsReceived); // 1 x LOADING + 1 x commit } - // The transaction's effective retry category is the most side-effecting of its operations. An INCR makes - // the whole transaction "accumulating", which the default policy (capped at write-last-wins) refuses to - // retry - so a transient EXEC failure surfaces and the per-op proxy faults rather than hanging. Raising - // the cap to allow accumulating writes lets the same transaction ride the failure out; and because the - // failed attempt is discarded server-side, the INCR applies exactly once (no double-count). + // A transaction's effective retry category is the most side-effecting of its operations, so an INCR + // makes the whole thing "accumulating" - beyond what the default policy (capped at write-last-wins) + // would normally repeat. But a LOADING reply to EXEC *proves* the server discarded the transaction + // wholesale, so replaying it cannot double-count: the category cap does not apply, and even the + // default policy rides it out. Without that distinction, most interesting transactions (i.e. the ones + // that mutate something) would never be retryable at all. [Fact] - public async Task WithRetry_Transaction_AccumulatingOp_RespectsCategoryGate() + public async Task WithRetry_Transaction_RejectedExec_RetriesRegardlessOfCategory() { using var server = new ExecFailServer(Output); await using var conn = await server.ConnectAsync(log: Writer); @@ -179,30 +330,52 @@ public async Task WithRetry_Transaction_AccumulatingOp_RespectsCategoryGate() var db = conn.GetDatabase(); RedisKey key = "retry:tran:incr"; - // default cap = write-last-wins; an INCR makes the aggregate accumulating -> NOT retried - RetryPolicy conservative = new RetryPolicy.Builder { MaxAttempts = 3, RetryDelay = TimeSpan.Zero, JitterMax = TimeSpan.Zero }; + RetryPolicy policy = new RetryPolicy.Builder { MaxAttempts = 3, RetryDelay = TimeSpan.Zero, JitterMax = TimeSpan.Zero }; + Assert.Equal(CommandFlags.CommandRetryWriteLastWins, policy.MaxCommandRetryCategory); // i.e. the default + server.FailExecOps = 1; - var tran1 = db.WithRetry(conservative).CreateTransaction(); - var incr1 = tran1.StringIncrementAsync(key); - await Assert.ThrowsAsync(async () => await tran1.ExecuteAsync()); - await Assert.ThrowsAsync(async () => await incr1); // proxy faulted, not left hanging - Assert.Equal(1, server.ExecOpsReceived); // gave up immediately, no retry - Assert.Equal(0, server.FailExecOps); - - // raise the cap to allow accumulating writes: the same transaction now rides out the transient failure - RetryPolicy permissive = new RetryPolicy.Builder + var tran = db.WithRetry(policy).CreateTransaction(); + var incr = tran.StringIncrementAsync(key); + + Assert.True(await tran.ExecuteAsync()); + Assert.Equal(1, await incr); // the discarded attempt applied nothing -> incremented exactly once + Assert.Equal(2, server.ExecOpsReceived); // 1 x LOADING + 1 x commit + } + + // The other half of that story: when the outcome is genuinely *ambiguous*, the category still bites. + // OOM is deliberately *not* treated as "known not applied": a Lua script can hit the memory limit + // part-way through, having already written something, and it reports the inner error verbatim - so from + // the client's side an OOM reply proves nothing about whether the command took effect. Here the server + // models exactly that: it applies the INCR and *then* reports OOM. Under the default cap the fault + // surfaces after one attempt; raising the cap opts into replaying it, and the value shows the + // triple-count that the cap exists to prevent. + [Theory] + [InlineData(false, 1)] // default cap: not repeated + [InlineData(true, 3)] // caller opted in: repeated, and every attempt landed + public async Task WithRetry_AmbiguousFault_IsStillGatedByCategory(bool allowAccumulating, int expectedValue) + { + using var server = new AppliedThenFailedServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + + var db = conn.GetDatabase(); + RedisKey key = "retry:ambiguous:incr"; + + RetryPolicy policy = new RetryPolicy.Builder { MaxAttempts = 3, RetryDelay = TimeSpan.Zero, JitterMax = TimeSpan.Zero, - MaxCommandRetryCategory = CommandFlags.CommandRetryWriteAccumulating, + MaxCommandRetryCategory = allowAccumulating + ? CommandFlags.CommandRetryWriteAccumulating + : RetryPolicy.Default.MaxCommandRetryCategory, }; - server.FailExecOps = 1; - var tran2 = db.WithRetry(permissive).CreateTransaction(); - var incr2 = tran2.StringIncrementAsync(key); - Assert.True(await tran2.ExecuteAsync()); - Assert.Equal(1, await incr2); // discarded attempt did NOT apply -> incremented exactly once - Assert.Equal(3, server.ExecOpsReceived); // 1 (first block) + 2 (LOADING + commit) + + var ex = await Assert.ThrowsAsync(async () => await db.WithRetry(policy).StringIncrementAsync(key)); + Assert.Equal(RedisErrorKind.OutOfMemory, ex.Kind); + + Assert.Equal(expectedValue, server.IncrOpsReceived); + server.FailIncr = false; + Assert.Equal(expectedValue, (long)await db.StringGetAsync(key)); // and each one really did apply } // A WATCH constraint is replayed on every attempt. Here the condition is satisfied, and the first EXEC @@ -372,14 +545,241 @@ public async Task WithRetry_Transaction_FailsOverBetweenGroups() Assert.Equal("committed-on-B", await checkB.GetDatabase().StringGetAsync(key)); } + // Replay has to be repeatable, not just possible once: two transient EXEC failures in a row, with a + // mixed bag of operations, and every proxy still resolves from the third (winning) attempt. + [Fact] + public async Task WithRetry_Transaction_ReplaysRepeatedly() + { + using var server = new ExecFailServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + + var db = conn.GetDatabase(); + RedisKey key = "retry:tran:repeat", counter = "retry:tran:repeat:count", list = "retry:tran:repeat:list"; + Assert.True(await db.StringSetAsync(key, "seed")); + + server.FailExecOps = 2; // fail twice; the third EXEC commits + + RetryPolicy policy = new RetryPolicy.Builder + { + MaxAttempts = 4, + RetryDelay = TimeSpan.Zero, + JitterMax = TimeSpan.Zero, + MaxCommandRetryCategory = CommandFlags.CommandRetryWriteAccumulating, // INCR/LPUSH are accumulating + }; + var tran = db.WithRetry(policy).CreateTransaction(); + var cond = tran.AddCondition(Condition.StringEqual(key, "seed")); + var set = tran.StringSetAsync(key, "committed"); + var incr = tran.StringIncrementAsync(counter); + var push = tran.ListLeftPushAsync(list, "item"); + var get = tran.StringGetAsync(key); + + Assert.True(await tran.ExecuteAsync()); + + Assert.True(cond.WasSatisfied); + Assert.True(await set); + Assert.Equal(1, await incr); // the two discarded attempts applied nothing + Assert.Equal(1, await push); + Assert.Equal("committed", await get); + Assert.Equal(3, server.ExecOpsReceived); // 2 x LOADING + 1 x commit + } + + // When a transaction runs out of attempts, the failure must reach *every* per-operation proxy as well + // as the ExecuteAsync caller; a proxy left unresolved would hang the caller forever. + [Fact] + public async Task WithRetry_Transaction_WhenAttemptsExhausted_FaultsEveryProxy() + { + using var server = new ExecFailServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + + var db = conn.GetDatabase(); + RedisKey key = "retry:tran:exhaust"; + + server.FailExecOps = 100; // never succeeds + + RetryPolicy policy = new RetryPolicy.Builder { MaxAttempts = 2, RetryDelay = TimeSpan.Zero, JitterMax = TimeSpan.Zero }; + var tran = db.WithRetry(policy).CreateTransaction(); + var set = tran.StringSetAsync(key, "never"); + var get = tran.StringGetAsync(key); + + var fault = await Assert.ThrowsAsync(async () => await tran.ExecuteAsync()); + Assert.Equal(RedisErrorKind.Loading, fault.Kind); + + // both proxies carry the same terminal fault rather than being left pending + Assert.Same(fault, await Assert.ThrowsAsync(async () => await set)); + Assert.Same(fault, await Assert.ThrowsAsync(async () => await get)); + Assert.Equal(2, server.ExecOpsReceived); // exactly MaxAttempts + } + + // WATCH drift under retry: the condition holds, so EXEC really is issued, but the server refuses it + // because a watched key moved. Nothing was applied and nothing faulted - we simply lost a race - so + // the transaction is re-attempted (re-reading the condition), and the second attempt commits. This is + // contention, not a fault, so the fault budget is untouched and the side-effect category is irrelevant. + [Fact] + public async Task WithRetry_Transaction_WatchDrift_IsReattempted() + { + using var server = new ExecFailServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + Assert.True(conn.IsConnected); + + var db = conn.GetDatabase(); + RedisKey key = "retry:tran:drift"; + Assert.True(await db.StringSetAsync(key, "seed")); + + server.DriftKey = key; + server.DriftOps = 1; // the next EXEC observes a concurrent write to the watched key + + // MaxAttempts = 1: no *fault* retries at all, proving the watch budget is separate + RetryPolicy policy = new RetryPolicy.Builder { MaxAttempts = 1, RetryDelay = TimeSpan.Zero, JitterMax = TimeSpan.Zero }; + var tran = db.WithRetry(policy).CreateTransaction(); + var cond = tran.AddCondition(Condition.StringEqual(key, "seed")); + var setTask = tran.StringSetAsync(key, "committed"); + + var execute = tran.ExecuteAsync(); + if (await Task.WhenAny(execute, Task.Delay(5000)) != execute) + { + Assert.Fail("ExecuteAsync never completed"); + } + + Assert.True(await execute); // rode out the lost race + Assert.True(cond.WasSatisfied); + Assert.True(await setTask); + Assert.False(tran.WasWatchConflict); // reports the *final* attempt: we got there in the end + Assert.Equal(2, server.ExecOpsReceived); // 1 x watch conflict + 1 x commit + Assert.Equal("committed", await db.StringGetAsync(key)); + } + + // Watch contention is bounded, and the bound is opt-out-able: with MaxAttemptsOnWatchConflict = 1 a + // conflict aborts exactly as it did before. ExecuteAsync reports false with every condition satisfied + // (which is what distinguishes drift from an elective abort), and the per-operation proxies are + // cancelled rather than left dangling - the case that used to hang the caller outright. + [Fact] + public async Task WithRetry_Transaction_WatchDrift_CanBeDisabled() + { + using var server = new ExecFailServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + + var db = conn.GetDatabase(); + RedisKey key = "retry:tran:drift:off"; + Assert.True(await db.StringSetAsync(key, "seed")); + + server.DriftKey = key; + server.DriftOps = 1; + + RetryPolicy policy = new RetryPolicy.Builder + { + MaxAttempts = 3, + MaxAttemptsOnWatchConflict = 1, // i.e. do not re-attempt on contention + RetryDelay = TimeSpan.Zero, + JitterMax = TimeSpan.Zero, + }; + var tran = db.WithRetry(policy).CreateTransaction(); + var cond = tran.AddCondition(Condition.StringEqual(key, "seed")); + var setTask = tran.StringSetAsync(key, "committed"); + + var execute = tran.ExecuteAsync(); + if (await Task.WhenAny(execute, Task.Delay(5000)) != execute) + { + Assert.Fail("ExecuteAsync never completed"); + } + + Assert.False(await execute); + Assert.True(cond.WasSatisfied); // the condition held; the server-side WATCH is what killed it + Assert.True(tran.WasWatchConflict); // ...and the caller can see exactly that + Assert.Equal(1, server.ExecOpsReceived); + await Assert.ThrowsAnyAsync(async () => await setTask); + Assert.Equal("seed", await db.StringGetAsync(key)); // nothing was applied + } + + // Contention that never clears must not loop forever: the server conflicts on every EXEC, so we give + // up after MaxAttemptsOnWatchConflict attempts and report the ordinary "did not commit" outcome. + [Fact] + public async Task WithRetry_Transaction_PersistentWatchDrift_GivesUp() + { + using var server = new ExecFailServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + + var db = conn.GetDatabase(); + RedisKey key = "retry:tran:drift:forever"; + Assert.True(await db.StringSetAsync(key, "seed")); + + server.DriftKey = key; + server.DriftOps = int.MaxValue; // every EXEC loses the race + + RetryPolicy policy = new RetryPolicy.Builder + { + MaxAttempts = 3, + MaxAttemptsOnWatchConflict = 4, + RetryDelay = TimeSpan.Zero, + JitterMax = TimeSpan.Zero, + }; + var tran = db.WithRetry(policy).CreateTransaction(); + var cond = tran.AddCondition(Condition.StringEqual(key, "seed")); + var setTask = tran.StringSetAsync(key, "committed"); + + Assert.False(await tran.ExecuteAsync()); + Assert.True(cond.WasSatisfied); + Assert.True(tran.WasWatchConflict); // still losing the race when we ran out of attempts + Assert.Equal(4, server.ExecOpsReceived); // bounded by MaxAttemptsOnWatchConflict + await Assert.ThrowsAnyAsync(async () => await setTask); + Assert.Equal("seed", await db.StringGetAsync(key)); + } + + // A transaction with no conditions has no WATCH, so it can never lose a watch race; the watch budget + // must not be spent on the ordinary "aborted" path. (Belt and braces: the aggregate outcome here is a + // clean commit, so this mostly guards against the budget logic firing on a false positive.) + [Fact] + public async Task WithRetry_Transaction_WithoutConditions_IgnoresWatchBudget() + { + using var server = new ExecFailServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + + var db = conn.GetDatabase(); + RedisKey key = "retry:tran:nocond"; + + RetryPolicy policy = new RetryPolicy.Builder { MaxAttempts = 1, MaxAttemptsOnWatchConflict = 5, RetryDelay = TimeSpan.Zero, JitterMax = TimeSpan.Zero }; + var tran = db.WithRetry(policy).CreateTransaction(); + var set = tran.StringSetAsync(key, "committed"); + + Assert.True(await tran.ExecuteAsync()); + Assert.True(await set); + Assert.Equal(1, server.ExecOpsReceived); + } + + // An in-proc server that *applies* each INCR and then reports OOM: the write happened, but the client + // has no way to know that. Counts the applications so a test can tell "the client gave up" from "the + // client repeated a write it could not account for". + private sealed class AppliedThenFailedServer(ITestOutputHelper? log) : InProcessTestServer(log) + { + private int _incrOpsReceived; + + public int IncrOpsReceived => Volatile.Read(ref _incrOpsReceived); + + public bool FailIncr { get; set; } = true; + + protected override TypedRedisValue Incr(RedisClient client, in RedisRequest request) + { + var applied = base.Incr(client, in request); + if (!FailIncr) return applied; + + Interlocked.Increment(ref _incrOpsReceived); + return TypedRedisValue.Error("OOM command not allowed when used memory > 'maxmemory'"); + } + } + // An in-proc server that fails the first FailExecOps EXEC operations with a transient LOADING error, - // discarding that attempt's queued commands so nothing is applied, then commits normally. + // discarding that attempt's queued commands so nothing is applied, then commits normally. It can + // also simulate WATCH drift (a concurrent write to DriftKey immediately before EXEC is processed), + // which is a clean server-side rejection rather than a fault. private sealed class ExecFailServer(ITestOutputHelper? log, EndPoint? endpoint = null) : InProcessTestServer(log, endpoint) { public int ExecOpsReceived { get; private set; } public int FailExecOps { get; set; } + public int DriftOps { get; set; } + + public RedisKey DriftKey { get; set; } + protected override TypedRedisValue Exec(RedisClient client, in RedisRequest request) { ExecOpsReceived++; @@ -391,6 +791,12 @@ protected override TypedRedisValue Exec(RedisClient client, in RedisRequest requ return TypedRedisValue.Error("LOADING Redis is loading the dataset in memory"); } + if (DriftOps > 0) + { + DriftOps--; + client.Touch(client.Database, DriftKey); // as if another connection wrote the watched key + } + return base.Exec(client, in request); } } @@ -398,7 +804,7 @@ protected override TypedRedisValue Exec(RedisClient client, in RedisRequest requ // An in-proc server that fails the first LoadingOps GET operations with a transient LOADING error // (decrementing the counter each time), then serves normally. Every GET bumps GetOpsReceived so the // test can confirm how many attempts actually reached the server. - private sealed class LoadingServer(ITestOutputHelper? log) : InProcessTestServer(log) + private sealed class LoadingServer(ITestOutputHelper? log, EndPoint? endpoint = null) : InProcessTestServer(log, endpoint) { // the server core processes operations under a lock (single-threaded, like Redis), so plain fields // are fine here @@ -406,6 +812,27 @@ private sealed class LoadingServer(ITestOutputHelper? log) : InProcessTestServer public int LoadingOps { get; set; } + // the error to reply with; transient by default, but overridable so the same harness can present + // a fault that is *not* worth retrying + public string ErrorText { get; set; } = "LOADING Redis is loading the dataset in memory"; + + public int UnknownOpsReceived { get; private set; } + + // a command the *client* cannot categorise; answered from the same LoadingOps budget so the retry + // decision is isolated to the category, not the error kind + public override TypedRedisValue OnUnknownCommand(RedisClient client, in RedisRequest request, ReadOnlySpan command) + { + UnknownOpsReceived++; + + if (LoadingOps > 0) + { + LoadingOps--; + return TypedRedisValue.Error(ErrorText); + } + + return TypedRedisValue.SimpleString("made-up-ok"); + } + protected override TypedRedisValue Get(RedisClient client, in RedisRequest request) { GetOpsReceived++; @@ -414,7 +841,7 @@ protected override TypedRedisValue Get(RedisClient client, in RedisRequest reque if (LoadingOps > 0) { LoadingOps--; - return TypedRedisValue.Error("LOADING Redis is loading the dataset in memory"); + return TypedRedisValue.Error(ErrorText); } return base.Get(client, in request); diff --git a/tests/StackExchange.Redis.Tests/RetryTests/RetryGuardTests.cs b/tests/StackExchange.Redis.Tests/RetryTests/RetryGuardTests.cs new file mode 100644 index 0000000000..64ea62f7e4 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RetryTests/RetryGuardTests.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using StackExchange.Redis.Availability; +using StackExchange.Redis.KeyspaceIsolation; +using Xunit; + +namespace StackExchange.Redis.Tests.RetryTests; + +// The rejection rules and hand-written members of the retry wrappers: what WithRetry refuses to wrap, +// what a retrying transaction refuses to do, and the members that are deliberately *not* retried +// (status probes, routing lookups, streaming scans) and so are implemented by hand. +public class RetryGuardTests(ITestOutputHelper log) : TestBase(log) +{ + private static RetryPolicy Policy() => new RetryPolicy.Builder { MaxAttempts = 3, RetryDelay = TimeSpan.Zero, JitterMax = TimeSpan.Zero }; + + // Retrying inside a batch or a transaction makes no sense (the individual operations are not being + // dispatched yet), and retry cannot be nested. All three are refused at wrap time. + [Fact] + public async Task WithRetry_RefusesBatchTransactionAndNesting() + { + using var server = new InProcessTestServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + var db = conn.GetDatabase(); + + Assert.Throws(() => db.CreateBatch().WithRetry(Policy())); + Assert.Throws(() => db.CreateTransaction().WithRetry(Policy())); + Assert.Throws(() => db.WithRetry(Policy()).WithRetry(Policy())); + } + + // A database's asyncState is stamped onto the task produced by a single dispatch. A retrying database + // hands back its own task spanning however many attempts it takes, so it cannot carry that state - + // and silently dropping it would be worse than refusing. + [Fact] + public async Task WithRetry_RefusesAsyncState() + { + using var server = new InProcessTestServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + + object state = new(); + var ex = Assert.Throws(() => conn.GetDatabase(0, state).WithRetry(Policy())); + Log(ex.Message); + + // ...including via a key-prefixed view of such a database, which inherits the inner state + Assert.Throws(() => conn.GetDatabase(0, state).WithKeyPrefix("p:").WithRetry(Policy())); + + // and the same applies to a transaction created *from* a retrying database + var retryDb = conn.GetDatabase().WithRetry(Policy()); + Assert.Throws(() => retryDb.CreateTransaction(state)); + + // no state: fine + Assert.NotNull(conn.GetDatabase().WithKeyPrefix("p:").WithRetry(Policy())); + Assert.NotNull(retryDb.CreateTransaction()); + } + + // Key-prefixing composes with retry: the prefix is applied when the operation is captured, so it + // survives being replayed. (Only one nesting order is expressible, since WithKeyPrefix needs an + // IDatabase and WithRetry produces an async-only database.) + [Fact] + public async Task WithRetry_ComposesWithKeyPrefix() + { + using var server = new InProcessTestServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + + var db = conn.GetDatabase().WithKeyPrefix("pfx:").WithRetry(Policy()); + Assert.True(await db.StringSetAsync("key", "value")); + + // visible under the prefixed name from an unprefixed database + Assert.Equal("value", await conn.GetDatabase().StringGetAsync("pfx:key")); + } + + // The transaction lifecycle guards: execute once, and do not accept work afterwards. + [Fact] + public async Task RetryTransaction_RefusesReuse() + { + using var server = new InProcessTestServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + + var tran = conn.GetDatabase().WithRetry(Policy()).CreateTransaction(); + var set = tran.StringSetAsync("guard:key", "value"); + Assert.True(await tran.ExecuteAsync()); + Assert.True(await set); + + await Assert.ThrowsAsync(async () => await tran.ExecuteAsync()); + Assert.Throws(() => { _ = tran.StringSetAsync("guard:key", "again"); }); + Assert.Throws(() => tran.AddCondition(Condition.KeyExists("guard:key"))); + } + + // Nested transactions, and the cursored scans, cannot participate in a transaction at all. + [Fact] + public async Task RetryTransaction_RefusesNestingAndScans() + { + using var server = new InProcessTestServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + + var tran = conn.GetDatabase().WithRetry(Policy()).CreateTransaction(); + + Assert.Throws(() => tran.CreateTransaction()); + Assert.Throws(() => tran.HashScanAsync("k")); + Assert.Throws(() => tran.HashScanNoValuesAsync("k")); + Assert.Throws(() => tran.SetScanAsync("k")); + Assert.Throws(() => tran.SortedSetScanAsync("k")); + Assert.Throws(() => tran.VectorSetRangeEnumerateAsync("k", "a", "z")); + } + + // Scans *can* be used on a retrying database: they are cursored, so they cannot be captured and + // replayed as a unit, but rather than refusing them outright we forward straight through (giving up + // retry, keeping the scan working). Needs a real server: the managed one has no *SCAN support. + [Fact] + public async Task WithRetry_ForwardsScans() + { + await using var conn = Create(); + + var inner = conn.GetDatabase(); + RedisKey hash = Me() + ":hash", set = Me() + ":set", zset = Me() + ":zset"; + await inner.KeyDeleteAsync([hash, set, zset]); + await inner.HashSetAsync(hash, [new HashEntry("a", "1"), new HashEntry("b", "2")]); + await inner.SetAddAsync(set, ["x", "y"]); + await inner.SortedSetAddAsync(zset, [new SortedSetEntry("p", 1), new SortedSetEntry("q", 2)]); + + var db = inner.WithRetry(Policy()); + + Assert.Equal(2, await CountAsync(db.HashScanAsync(hash))); + Assert.Equal(2, await CountAsync(db.HashScanNoValuesAsync(hash))); + Assert.Equal(2, await CountAsync(db.SetScanAsync(set))); + Assert.Equal(2, await CountAsync(db.SortedSetScanAsync(zset))); + } + + // Commands with no return value (a bare Task, not Task) go through their own funnel in the retry + // database, and their own recorded-operation type inside a retrying transaction. Needs a real server: + // the managed one implements no void-shaped command, so the fault/replay variant of this cannot + // currently be driven in-process. + [Fact] + public async Task WithRetry_HandlesVoidOperations() + { + await using var conn = Create(); + + var inner = conn.GetDatabase(); + RedisKey key = Me(); + await inner.KeyDeleteAsync(key); + await inner.ListRightPushAsync(key, ["a", "b", "c", "d"]); + + var db = inner.WithRetry(Policy()); + await db.ListTrimAsync(key, 0, 1); // Task, not Task + Assert.Equal(2, await db.ListLengthAsync(key)); + + // and the same shape recorded into (and replayed by) a retrying transaction + var tran = db.CreateTransaction(); + var trim = tran.ListTrimAsync(key, 0, 0); + var length = tran.ListLengthAsync(key); + Assert.True(await tran.ExecuteAsync()); + + await trim; // the void proxy resolved rather than hanging + Assert.True(trim.IsCompletedSuccessfully); + Assert.Equal(1, await length); + } + + // The cheap status/routing members are pass-throughs rather than retried round-trips. + [Fact] + public async Task WithRetry_ForwardsProbes() + { + using var server = new InProcessTestServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + + var inner = conn.GetDatabase(); + var db = inner.WithRetry(Policy()); + RedisKey key = "probe:key"; + + Assert.Equal(inner.Database, db.Database); + Assert.Same(conn, db.Multiplexer); + Assert.True(db.IsConnected(key)); + Assert.NotNull(await db.IdentifyEndpointAsync(key)); + Log(db.ToString()!); // exercises the feature-flag description + } + + private static async Task CountAsync(IAsyncEnumerable source) + { + int count = 0; + await foreach (var _ in source) count++; + return count; + } +} diff --git a/tests/StackExchange.Redis.Tests/TransactionWatchDriftTests.cs b/tests/StackExchange.Redis.Tests/TransactionWatchDriftTests.cs new file mode 100644 index 0000000000..7b4f1bc263 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/TransactionWatchDriftTests.cs @@ -0,0 +1,189 @@ +using System.Net; +using System.Threading.Tasks; +using RESPite.Messages; +using StackExchange.Redis.Server; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Covers the "WATCH drift" outcome of a conditional transaction: every condition was satisfied, so +/// MULTI/EXEC really was issued, but a watched key changed underneath us and the server +/// answered EXEC with a null array. This is distinct from an *elective* abort (a condition that +/// failed, where no EXEC is sent at all) and, unlike that case, it can only be produced by a +/// concurrent write - so it needs the in-process server to drive it deterministically. +/// +[RunPerProtocol] +public class TransactionWatchDriftTests(ITestOutputHelper log) : TestBase(log) +{ + // A null array is not an empty array: EXEC answering *-1 (RESP2) / _ (RESP3) means "watch failed", + // whereas *0 means "a transaction of zero commands committed". The managed server used to collapse + // the former into the latter, which made the whole drift path untestable (and, client-side, it + // surfaced as a protocol failure instead). + [Fact] + public void NullArrayIsDistinctFromEmptyArray() + { + var nullArray = TypedRedisValue.NullArray(RespPrefix.Array); + Assert.True(nullArray.IsNullArray); + Assert.True(nullArray.IsNullValueOrArray); + Assert.True(nullArray.Span.IsEmpty); + + var emptyArray = TypedRedisValue.EmptyArray(RespPrefix.Array); + Assert.False(emptyArray.IsNullArray); + Assert.False(emptyArray.IsNullValueOrArray); + Assert.True(emptyArray.Span.IsEmpty); + } + + // The headline case: the condition holds, EXEC is issued, and the server rejects it because the + // watched key moved. Execute reports false (nothing was applied) and - the part that regressed - + // every queued operation's task must reach a terminal state (cancelled), not hang forever. + [Fact] + public async Task WatchDrift_AbortsAndCancelsQueuedOperations() + { + using var server = new WatchDriftServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + + var db = conn.GetDatabase(); + RedisKey key = "drift:cancel"; + Assert.True(await db.StringSetAsync(key, "seed")); + + server.DriftKey = key; + server.DriftOps = 1; // the next EXEC observes a concurrent write to the watched key + + var tran = db.CreateTransaction(); + var cond = tran.AddCondition(Condition.StringEqual(key, "seed")); + var setTask = tran.StringSetAsync(key, "committed"); + var incrTask = tran.StringIncrementAsync("drift:counter"); + + Assert.False(await tran.ExecuteAsync()); // EXEC returned a null array + Assert.True(cond.WasSatisfied); // the *condition* held; the server-side WATCH is what killed it + Assert.True(tran.WasWatchConflict); // ...and this is how a caller tells the two apart + Assert.Equal(1, server.ExecOpsReceived); + + // both per-operation tasks must complete (as cancelled); before the fix they sat forever in + // WaitingForActivation, so assert with a timeout rather than awaiting them directly + await AssertCancelledAsync(setTask); + await AssertCancelledAsync(incrTask); + + Assert.Equal("seed", await db.StringGetAsync(key)); // nothing was applied + Assert.False(await db.KeyExistsAsync("drift:counter")); + } + + // Same shape, but confirming the *elective* abort still behaves: the condition fails, no EXEC is + // ever issued, and the queued operations are cancelled. This is the path that already worked; it is + // here so the two outcomes are pinned side by side (they are indistinguishable from Execute's bool + // alone - WasWatchConflict, or inspecting the ConditionResults, is what separates them). + [Fact] + public async Task FailedCondition_AbortsElectively_WithoutExec() + { + using var server = new WatchDriftServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + + var db = conn.GetDatabase(); + RedisKey key = "drift:elective"; + Assert.True(await db.StringSetAsync(key, "seed")); + + var tran = db.CreateTransaction(); + Assert.False(tran.WasWatchConflict); // false before execution, too + + var cond = tran.AddCondition(Condition.StringEqual(key, "different")); + var setTask = tran.StringSetAsync(key, "committed"); + + Assert.False(await tran.ExecuteAsync()); + Assert.False(cond.WasSatisfied); // this is what distinguishes an elective abort from drift + Assert.False(tran.WasWatchConflict); // we chose not to issue an EXEC; nobody raced us + Assert.Equal(0, server.ExecOpsReceived); // never even asked + + await AssertCancelledAsync(setTask); + Assert.Equal("seed", await db.StringGetAsync(key)); + } + + // A transaction with conditions but no operations: drift still aborts it, and there are no + // per-operation tasks to cancel. Guards the zero-length inner-operations edge in the processor. + [Fact] + public async Task WatchDrift_ConditionOnlyTransaction_Aborts() + { + using var server = new WatchDriftServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + + var db = conn.GetDatabase(); + RedisKey key = "drift:condonly"; + Assert.True(await db.StringSetAsync(key, "seed")); + + server.DriftKey = key; + server.DriftOps = 1; + + var tran = db.CreateTransaction(); + var cond = tran.AddCondition(Condition.StringEqual(key, "seed")); + + Assert.False(await tran.ExecuteAsync()); + Assert.True(cond.WasSatisfied); + Assert.True(tran.WasWatchConflict); + Assert.Equal(1, server.ExecOpsReceived); + } + + // A transaction that commits cleanly must not report a conflict. + [Fact] + public async Task SatisfiedCondition_Commits_WithoutConflict() + { + using var server = new WatchDriftServer(Output); + await using var conn = await server.ConnectAsync(log: Writer); + + var db = conn.GetDatabase(); + RedisKey key = "drift:clean"; + Assert.True(await db.StringSetAsync(key, "seed")); + + var tran = db.CreateTransaction(); + var cond = tran.AddCondition(Condition.StringEqual(key, "seed")); + var setTask = tran.StringSetAsync(key, "committed"); + + Assert.True(await tran.ExecuteAsync()); + Assert.True(cond.WasSatisfied); + Assert.False(tran.WasWatchConflict); + Assert.True(await setTask); + } + + private async Task AssertCancelledAsync(Task task) + { + var completed = await Task.WhenAny(task, Task.Delay(5000)); + if (completed != task) + { + Log($"task did not complete; status: {task.Status}"); + Assert.Fail($"queued operation never completed (status: {task.Status})"); + } + + await Assert.ThrowsAnyAsync(async () => await task); + } + + // An in-process server that, for the next DriftOps EXEC operations, simulates a concurrent write to + // DriftKey immediately before the EXEC is processed. Touch is exactly what a real write from another + // connection would do, so the transaction is doomed by the server's own WATCH bookkeeping and EXEC + // replies with a null array - no special-casing of the reply itself. + // + // Driving this from a genuinely separate connection is not practical: SE.Redis does not issue the WATCH + // when AddCondition is called, it issues WATCH, the condition reads, MULTI, the queued commands and + // EXEC as one dispatch. So the window an interloper has to squeeze into is the gap between the + // condition reads and the EXEC landing, within a single flush - which is the point of the feature, but + // makes it useless as a test lever. Injecting the Touch server-side reproduces the same state exactly. + private sealed class WatchDriftServer(ITestOutputHelper? log, EndPoint? endpoint = null) : InProcessTestServer(log, endpoint) + { + public int ExecOpsReceived { get; private set; } + + public int DriftOps { get; set; } + + public RedisKey DriftKey { get; set; } + + protected override TypedRedisValue Exec(RedisClient client, in RedisRequest request) + { + ExecOpsReceived++; + + if (DriftOps > 0) + { + DriftOps--; + client.Touch(client.Database, DriftKey); + } + + return base.Exec(client, in request); + } + } +} diff --git a/toys/StackExchange.Redis.Server/TypedRedisValue.cs b/toys/StackExchange.Redis.Server/TypedRedisValue.cs index d3264a1ecb..da5d33a42a 100644 --- a/toys/StackExchange.Redis.Server/TypedRedisValue.cs +++ b/toys/StackExchange.Redis.Server/TypedRedisValue.cs @@ -194,14 +194,17 @@ private TypedRedisValue(TypedRedisValue[] oversizedItems, int count, RespPrefix if (oversizedItems == null) { if (count != 0) throw new ArgumentOutOfRangeException(nameof(count)); - oversizedItems = []; - } - else - { - if (count < 0 || count > oversizedItems.Length) throw new ArgumentOutOfRangeException(nameof(count)); - if (count == 0) oversizedItems = []; + + // a *null* array is not the same as an empty array; keep the value null so that + // IsNullArray reports true and we emit *-1 / _ rather than *0 + _value = RedisValue.Null; + Type = type; + return; } + if (count < 0 || count > oversizedItems.Length) throw new ArgumentOutOfRangeException(nameof(count)); + if (count == 0) oversizedItems = []; + _value = RedisValue.CreateForeign(oversizedItems, 0, count); Type = type; } From 95fe8861b996704fbdb2ce30487a346712446653 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 30 Jul 2026 13:03:07 +0100 Subject: [PATCH 29/33] feature naming --- docs/Configuration.md | 4 +- ...ctiveActive.md => GeoRedundantFailover.md} | 40 ++++++++++++++----- docs/exp/SER007.md | 27 +++++++++++-- docs/index.md | 2 +- src/RESPite/Shared/Experiments.cs | 2 +- .../Availability/CircuitBreaker.cs | 4 +- .../Availability/DatabaseExtensions.cs | 2 +- .../Availability/FaultContext.cs | 2 +- .../Availability/HealthCheck.cs | 4 +- .../Availability/HealthCheckContext.cs | 2 +- .../Availability/HealthCheckProbe.cs | 4 +- .../Availability/HealthCheckProbeContext.cs | 2 +- .../Availability/HealthCheckProbePolicy.cs | 2 +- .../Availability/HealthCheckResult.cs | 2 +- .../Availability/MultiGroupMultiplexer.cs | 6 +-- .../Availability/MultiGroupOptions.cs | 4 +- .../Availability/RetryPolicy.cs | 4 +- .../Availability/RetryResult.cs | 2 +- .../ConfigurationOptions.cs | 4 +- src/StackExchange.Redis/Enums/CommandFlags.cs | 16 ++++---- .../Enums/ConnectionFailureType.cs | 2 +- .../Interfaces/IConnectionGroup.cs | 4 +- .../Interfaces/IDatabaseAsync.cs | 2 +- src/StackExchange.Redis/PhysicalConnection.cs | 2 +- src/StackExchange.Redis/RedisErrorKind.cs | 2 +- 25 files changed, 94 insertions(+), 53 deletions(-) rename docs/{ActiveActive.md => GeoRedundantFailover.md} (92%) diff --git a/docs/Configuration.md b/docs/Configuration.md index 8e51f4146b..e622ed8af9 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -139,9 +139,9 @@ Additional code-only options: - ResponseBufferPool (`MemoryPool`) - Default: `null` - The buffer pool to use when buffering responses (and for allocating `Lease` results); when `null`, a shared default pool is used - CircuitBreaker (`CircuitBreaker`) - Default: `null` - - **[Experimental](exp/SER007)** (Active:Active). A per-connection circuit breaker that *passively* observes the outcome of normal traffic and tears the connection down when it becomes unstable. When the connection is a member of an Active:Active group, this flows in from `MultiGroupOptions.CircuitBreaker` if not set explicitly. See [Active:Active](ActiveActive) + - **[Experimental](exp/SER007)** (geo-redundant failover). A per-connection circuit breaker that *passively* observes the outcome of normal traffic and tears the connection down when it becomes unstable. When the connection is a member of a connection group, this flows in from `MultiGroupOptions.CircuitBreaker` if not set explicitly. See [Geo-Redundant Failover](GeoRedundantFailover) - HealthCheck (`HealthCheck`) - Default: `null` - - **[Experimental](exp/SER007)** (Active:Active). An *active* health check used when the connection is a member of an Active:Active group; when `null`, the group-level `MultiGroupOptions.HealthCheck` is used. See [Active:Active](ActiveActive) + - **[Experimental](exp/SER007)** (geo-redundant failover). An *active* health check used when the connection is a member of a connection group; when `null`, the group-level `MultiGroupOptions.HealthCheck` is used. See [Geo-Redundant Failover](GeoRedundantFailover) Tokens in the configuration string are comma-separated; any without an `=` sign are assumed to be redis server endpoints. Endpoints without an explicit port will use 6379 if ssl is not enabled, and 6380 if ssl is enabled. Tokens starting with `$` are taken to represent command maps, for example: `$config=cfg`. diff --git a/docs/ActiveActive.md b/docs/GeoRedundantFailover.md similarity index 92% rename from docs/ActiveActive.md rename to docs/GeoRedundantFailover.md index 2bcc069a84..a7e50e5e88 100644 --- a/docs/ActiveActive.md +++ b/docs/GeoRedundantFailover.md @@ -1,15 +1,35 @@ -# Active:Active +# Geo-Redundant Failover + +> **What this is, and what it is called elsewhere.** This feature does one thing: it moves traffic between several independent endpoints that can each serve +> your workload, using health checks, circuit breakers and retries to decide when to move. Nothing in it is tied to a particular server topology, which is why +> the naming here is about the *behaviour* (failover between redundant endpoints) rather than about any specific deployment. +> +> The deployment it is **designed** for is **Active:Active** (geo-distributed, bidirectionally-replicated) Redis - Redis Software / Redis Cloud Active-Active +> databases, or any other topology where the same logical dataset is reachable through more than one independent endpoint. That is where failover is closest to +> transparent: because the members replicate to each other, an operation that lands on the new endpoint still sees what you wrote to the old one. +> +> It works just as well across endpoints that are *not* replicated to each other - separate OSS servers or clusters, instances in different regions with no link +> between them - provided you accept what that means for your data. With no replication between members, each endpoint holds only what was written to it: a +> failover moves you to a *different* dataset, writes made before the failover are not visible after it, and the two do not converge afterwards. That is +> perfectly reasonable for a cache you are content to re-populate, or where the data is reconstructible from a system of record; it is not reasonable if you are +> treating the group as a single durable store. Only you can make that call - the library has no way to know which situation it is in. +> +> The shape will be familiar if you have used the **"multi-DB client"** concept in other Redis client libraries: Jedis' +> [`MultiDbClient`](https://redis.io/docs/latest/develop/clients/jedis/failover/) and redis-py's +> [`MultiDBClient`](https://redis.readthedocs.io/en/stable/multi_database.html) (see also Redis' overview of +> [client-side geographic failover for Active-Active](https://redis.io/blog/client-side-geographic-failover-for-redis-active-active/)) - a weighted set of +> endpoints, one active at a time, with health checks, circuit breakers and retries deciding when to move. ## Overview -The Active:Active feature provides automatic failover and intelligent routing across multiple Redis deployments. It is built from several cooperating pieces: +Geo-redundant failover provides automatic failover and intelligent routing across multiple Redis deployments. It is built from several cooperating pieces: 1. **Connecting to multiple servers or regions at once**, giving a deployment redundant endpoints to fall back on. 2. **Health checks** that *actively* probe each endpoint on a timer to monitor its availability. 3. **Circuit breakers** that *passively* monitor availability from the observed success and failure of the traffic already flowing. 4. **Automatic retries** that let straightforward operations ride out a possibly-unstable connection — including silently transitioning to another endpoint during a failover, with no change to your calling code. -The features for Active:Active are available in the `Availability` sub-namespace: +These features are available in the `Availability` sub-namespace: ``` csharp using StackExchange.Redis; @@ -78,7 +98,7 @@ This enables scenarios such as: ### Connecting to Multiple Groups -To create an Active:Active connection, use `ConnectionMultiplexer.ConnectGroupAsync()` with an array of `ConnectionGroupMember` instances: +To create a geo-redundant connection, use `ConnectionMultiplexer.ConnectGroupAsync()` with an array of `ConnectionGroupMember` instances: ```csharp // Define your Redis endpoints @@ -148,7 +168,7 @@ members[2].Weight = 10; // Increase preference for remote DC ## Working with IDatabase -The `IDatabase` interface works transparently with Active:Active connections. All operations are automatically routed to the currently selected endpoint: +The `IDatabase` interface works transparently with connection groups. All operations are automatically routed to the currently selected endpoint: ```csharp var db = conn.GetDatabase(); @@ -256,7 +276,7 @@ These are the same instances that were passed into `ConnectGroupAsync`. ## Health Checks -The Active:Active feature includes configurable health checking to monitor the health of all endpoints and automatically route traffic away from unhealthy instances. +Configurable health checking monitors the health of all endpoints and automatically routes traffic away from unhealthy instances. ### Basic Health Check Configuration @@ -464,8 +484,8 @@ Where health checks *actively* probe each member on a timer, a **circuit breaker - A **health check** answers *"is this member reachable?"* by sending its own probes every `Interval`. - A **circuit breaker** answers *"is the traffic I'm already sending actually succeeding?"* with no extra round-trips, reacting the instant real commands start failing. -When a circuit breaker trips, it shuts the underlying physical connection down with `ConnectionFailureType.CircuitBreaker`. The connection then reconnects as usual, and — in an Active:Active group — the health check and member-selection logic route traffic to other members until the affected member recovers. A circuit breaker is evaluated **per connection**, so -its state is scoped to exactly the connection whose health it is measuring; a reconnect starts from a clean slate - but breaking the connection is sufficient for Active:Active to notice non-availability. +When a circuit breaker trips, it shuts the underlying physical connection down with `ConnectionFailureType.CircuitBreaker`. The connection then reconnects as usual, and - in a connection group - the health check and member-selection logic route traffic to other members until the affected member recovers. A circuit breaker is evaluated **per connection**, so +its state is scoped to exactly the connection whose health it is measuring; a reconnect starts from a clean slate - but breaking the connection is sufficient for the group to notice non-availability. ### Configuring a Circuit Breaker for a Group @@ -537,7 +557,7 @@ MultiGroupOptions options = new MultiGroupOptions.Builder ## Automatic Retries -Health checks and circuit breakers keep the *group* pointed at a healthy member; **automatic retries** deal with the *individual operation* that was in flight when something went wrong. Wrapping a database with `WithRetry(...)` returns a database that transparently re-issues failed operations according to a `RetryPolicy` — riding out transient faults, and (in an Active:Active group) following a failover across to another member, without the caller having to catch-and-retry by hand. +Health checks and circuit breakers keep the *group* pointed at a healthy member; **automatic retries** deal with the *individual operation* that was in flight when something went wrong. Wrapping a database with `WithRetry(...)` returns a database that transparently re-issues failed operations according to a `RetryPolicy` — riding out transient faults, and (in a connection group) following a failover across to another member, without the caller having to catch-and-retry by hand. ```csharp await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members); @@ -555,7 +575,7 @@ var value = await db.StringGetAsync("mykey"); > **`asyncState` is not respected.** A database's `asyncState` is stamped onto the task produced by a single dispatch, but a retrying database hands back its own task spanning however many attempts the operation takes, and the per-operation tasks from `retryDb.CreateTransaction()` are durable proxies that outlive any single attempt. Neither can carry it. Rather than dropping the state silently, both refuse it: `conn.GetDatabase(0, asyncState).WithRetry(policy)` throws `InvalidOperationException`, as does `retryDb.CreateTransaction(asyncState)`. Wrap a database obtained without an `asyncState` - note that a key-prefixed view of a state-carrying database is refused too, since it inherits the inner state. -A retrying database can still *create* a transaction: `retryDb.CreateTransaction()` returns an `ITransactionAsync` whose `ExecuteAsync` is retried as a single unit. Each attempt replays the queued operations (and any `WATCH` constraints) against a fresh `MULTI`/`EXEC` - and, in an Active:Active group, onto whichever member is active at the time - so a transaction can ride out a failover just like a single command; the per-operation tasks handed back at build time resolve from the winning attempt. The retry-category gate (below) applies to the transaction as a whole, using the *most* side-effecting operation in it: a transaction containing an `INCR` counts as `CommandRetryWriteAccumulating`. In practice that gate rarely blocks a transaction, because the faults that a transaction is most likely to hit - a `MULTI`/`EXEC` the server *rejected* wholesale - are known not to have applied anything, and the category is not consulted in that case (see [Known-not-applied faults](#known-not-applied-faults)). +A retrying database can still *create* a transaction: `retryDb.CreateTransaction()` returns an `ITransactionAsync` whose `ExecuteAsync` is retried as a single unit. Each attempt replays the queued operations (and any `WATCH` constraints) against a fresh `MULTI`/`EXEC` - and, in a connection group, onto whichever member is active at the time - so a transaction can ride out a failover just like a single command; the per-operation tasks handed back at build time resolve from the winning attempt. The retry-category gate (below) applies to the transaction as a whole, using the *most* side-effecting operation in it: a transaction containing an `INCR` counts as `CommandRetryWriteAccumulating`. In practice that gate rarely blocks a transaction, because the faults that a transaction is most likely to hit - a `MULTI`/`EXEC` the server *rejected* wholesale - are known not to have applied anything, and the category is not consulted in that case (see [Known-not-applied faults](#known-not-applied-faults)). #### Losing a `WATCH` race diff --git a/docs/exp/SER007.md b/docs/exp/SER007.md index 7d7f64f5ab..b8478f76c6 100644 --- a/docs/exp/SER007.md +++ b/docs/exp/SER007.md @@ -1,6 +1,27 @@ -# Active:Active +# Geo-Redundant Failover -This feature is typically used to provide geo-redundant services; please see [full docs](/ActiveActive). +This feature is used to fail over between multiple redundant endpoints that can each serve your workload - designed for an +**Active:Active** (geo-distributed, bidirectionally-replicated) deployment, but usable over any set of endpoints, including ones +with no replication between them (see the full docs for what that means for your data). It is functionally similar to the +"multi-DB client" concept in other Redis clients (Jedis' `MultiDbClient`, redis-py's `MultiDBClient`); please see +[full docs](/GeoRedundantFailover). + +## Status: released and fully supported + +**This is a shipped, supported feature, intended for production use.** It is not a preview, it is not gated on an +unreleased server version, and it does not depend on any server API that is subject to change. Bugs in it are treated as +bugs, and can be reported and addressed in the usual way. No API changes are currently planned. + +The `[Experimental]` marker is nonetheless a real one: while it is present, we reserve the right to adjust this API surface +without the usual backwards-compatibility guarantees. That is the whole reason it is here. Geo-redundant failover is a +large, brand-new public API arriving in a single release, and once a surface that size is locked down it is locked down +for good - so we would rather keep room to correct a design wrinkle found in real-world use than commit to every detail +on day one. + +The marker is expected to be short-lived, and to be removed in a subsequent 3.1.x release once the design has had some +exposure. At that point the diagnostic simply stops being emitted, with no code change needed on your side. + +Suppressing the diagnostic is the normal way to use the feature; doing so acknowledges the above. To suppress this message, add the following to your `csproj` file: @@ -12,4 +33,4 @@ or more granularly / locally in C#: ``` c# #pragma warning disable SER007 -``` \ No newline at end of file +``` diff --git a/docs/index.md b/docs/index.md index 98b1a8378e..515d11b4dc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,7 +35,7 @@ Documentation - [Basic Usage](Basics) - getting started and basic usage - [Async Timeouts](AsyncTimeouts) - async timeouts and cancellation - [Configuration](Configuration) - options available when connecting to redis -- [Active:Active](ActiveActive) - connecting to multiple Redis endpoints for high availability +- [Geo-Redundant Failover](GeoRedundantFailover) (Active:Active / "multi-DB client") - connecting to multiple redundant Redis endpoints for high availability - [Pipelines and Multiplexers](PipelinesMultiplexers) - what is a multiplexer? - [Keys, Values and Channels](KeysValues) - discusses the data-types used on the API - [Transactions](Transactions) - how atomic transactions work in redis diff --git a/src/RESPite/Shared/Experiments.cs b/src/RESPite/Shared/Experiments.cs index 3413e84c5f..f72ece073b 100644 --- a/src/RESPite/Shared/Experiments.cs +++ b/src/RESPite/Shared/Experiments.cs @@ -18,7 +18,7 @@ internal static class Experiments // ReSharper disable InconsistentNaming public const string Respite = "SER004"; public const string UnitTesting = "SER005"; - public const string ActiveActive = "SER007"; + public const string GeoRedundantFailover = "SER007"; public const string Server_8_10 = "SER008"; // ReSharper restore InconsistentNaming diff --git a/src/StackExchange.Redis/Availability/CircuitBreaker.cs b/src/StackExchange.Redis/Availability/CircuitBreaker.cs index fc876898e7..2eb136922d 100644 --- a/src/StackExchange.Redis/Availability/CircuitBreaker.cs +++ b/src/StackExchange.Redis/Availability/CircuitBreaker.cs @@ -12,7 +12,7 @@ namespace StackExchange.Redis.Availability; /// /// Reports connection health by responding to observed success and failure conditions of processed messages. /// -[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +[Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public abstract class CircuitBreaker { internal const double DefaultFailureRateThreshold = 10; @@ -42,7 +42,7 @@ public abstract class CircuitBreaker /// /// Allows configuration of the default implementation. /// - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + [Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public sealed class Builder { /// diff --git a/src/StackExchange.Redis/Availability/DatabaseExtensions.cs b/src/StackExchange.Redis/Availability/DatabaseExtensions.cs index c082259d16..d25c9a8e05 100644 --- a/src/StackExchange.Redis/Availability/DatabaseExtensions.cs +++ b/src/StackExchange.Redis/Availability/DatabaseExtensions.cs @@ -32,7 +32,7 @@ public static class DatabaseExtensions /// /// If is a batch, a /// transaction, already retrying, or carries an asyncState. - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + [Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public static IDatabaseAsync WithRetry(this IDatabaseAsync database, RetryPolicy? retryPolicy = null) => new RetryDatabase(database, retryPolicy ?? ResolveRetryPolicy(database)); diff --git a/src/StackExchange.Redis/Availability/FaultContext.cs b/src/StackExchange.Redis/Availability/FaultContext.cs index 0c70f5d943..dbb055f156 100644 --- a/src/StackExchange.Redis/Availability/FaultContext.cs +++ b/src/StackExchange.Redis/Availability/FaultContext.cs @@ -7,7 +7,7 @@ namespace StackExchange.Redis.Availability; /// /// Provides information about a circuit-breaker test. /// -[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +[Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public readonly struct FaultContext { private readonly Exception? _fault; diff --git a/src/StackExchange.Redis/Availability/HealthCheck.cs b/src/StackExchange.Redis/Availability/HealthCheck.cs index 3419ad85df..c7d562ab1f 100644 --- a/src/StackExchange.Redis/Availability/HealthCheck.cs +++ b/src/StackExchange.Redis/Availability/HealthCheck.cs @@ -12,7 +12,7 @@ namespace StackExchange.Redis.Availability; /// a custom check. Note that how often checks run is a group-level concern, configured via /// , not a property of the check itself. /// -[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +[Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public sealed partial class HealthCheck { internal const int DefaultProbeCount = 3; @@ -100,7 +100,7 @@ public override string ToString() => IsEnabled /// /// Allows configuration of a . /// - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + [Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public sealed class Builder { /// diff --git a/src/StackExchange.Redis/Availability/HealthCheckContext.cs b/src/StackExchange.Redis/Availability/HealthCheckContext.cs index 8f7a7a13b5..6d3fd5037e 100644 --- a/src/StackExchange.Redis/Availability/HealthCheckContext.cs +++ b/src/StackExchange.Redis/Availability/HealthCheckContext.cs @@ -11,7 +11,7 @@ namespace StackExchange.Redis.Availability; /// This is passed by value rather than by in, because probe implementations are /// typically async, and async methods cannot take by-ref parameters. /// -[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +[Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public readonly struct HealthCheckContext(IServer server, TimeSpan probeTimeout) { /// diff --git a/src/StackExchange.Redis/Availability/HealthCheckProbe.cs b/src/StackExchange.Redis/Availability/HealthCheckProbe.cs index 56a6dc9a66..7816d805d6 100644 --- a/src/StackExchange.Redis/Availability/HealthCheckProbe.cs +++ b/src/StackExchange.Redis/Availability/HealthCheckProbe.cs @@ -8,7 +8,7 @@ namespace StackExchange.Redis.Availability; /// /// Describes an operation to perform as part of a health check. /// -[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +[Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public abstract partial class HealthCheckProbe { /// @@ -37,7 +37,7 @@ public abstract partial class HealthCheckProbe /// /// Describes a key-based (write) operation to perform as part of a health check. /// -[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +[Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public abstract class KeyWriteHealthCheckProbe : HealthCheckProbe { /// diff --git a/src/StackExchange.Redis/Availability/HealthCheckProbeContext.cs b/src/StackExchange.Redis/Availability/HealthCheckProbeContext.cs index 3feb5d8b98..39d19a75ea 100644 --- a/src/StackExchange.Redis/Availability/HealthCheckProbeContext.cs +++ b/src/StackExchange.Redis/Availability/HealthCheckProbeContext.cs @@ -7,7 +7,7 @@ namespace StackExchange.Redis.Availability; /// /// Represents the context of a health check probe. /// -[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +[Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public readonly struct HealthCheckProbeContext(HealthCheckResult result, int success, int failure, int remaining, TimeSpan probeInterval) { /// diff --git a/src/StackExchange.Redis/Availability/HealthCheckProbePolicy.cs b/src/StackExchange.Redis/Availability/HealthCheckProbePolicy.cs index 4eb6954757..cfbd824b73 100644 --- a/src/StackExchange.Redis/Availability/HealthCheckProbePolicy.cs +++ b/src/StackExchange.Redis/Availability/HealthCheckProbePolicy.cs @@ -7,7 +7,7 @@ namespace StackExchange.Redis.Availability; /// /// Attempt to evaluate the outcome of a series of health check operations. /// -[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +[Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public abstract class HealthCheckProbePolicy { /// diff --git a/src/StackExchange.Redis/Availability/HealthCheckResult.cs b/src/StackExchange.Redis/Availability/HealthCheckResult.cs index c8632507c1..79528f8e5d 100644 --- a/src/StackExchange.Redis/Availability/HealthCheckResult.cs +++ b/src/StackExchange.Redis/Availability/HealthCheckResult.cs @@ -6,7 +6,7 @@ namespace StackExchange.Redis.Availability; /// /// Indicates the result of a health check. /// -[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +[Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public enum HealthCheckResult { /// diff --git a/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs index 9505d55214..86cbb6922b 100644 --- a/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs @@ -24,7 +24,7 @@ public partial class ConnectionMultiplexer /// Additional options for configuring this group. /// The to log to. #pragma warning disable RS0026 - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + [Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public static Task ConnectGroupAsync( ConnectionGroupMember[] members, MultiGroupOptions? options = null, @@ -44,7 +44,7 @@ public static Task ConnectGroupAsync( /// An additional initial configuration to connect to. /// Additional options for configuring this group. /// The to log to. - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + [Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] #pragma warning disable RS0026 public static Task ConnectGroupAsync( ConnectionGroupMember member0, @@ -64,7 +64,7 @@ namespace Availability /// /// A configured member of a . /// - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + [Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] #pragma warning disable RS0016, RS0026 public sealed partial class ConnectionGroupMember(ConfigurationOptions configuration, string name = "") #pragma warning restore RS0016, RS0026 diff --git a/src/StackExchange.Redis/Availability/MultiGroupOptions.cs b/src/StackExchange.Redis/Availability/MultiGroupOptions.cs index f6260cb4fc..1f6f3eccf9 100644 --- a/src/StackExchange.Redis/Availability/MultiGroupOptions.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupOptions.cs @@ -12,7 +12,7 @@ namespace StackExchange.Redis.Availability; /// default, and can be overridden per-member by the matching property on /// (where one exists); the effective value is "member override, else group default". /// -[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +[Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public sealed class MultiGroupOptions { internal static readonly TimeSpan DefaultHealthCheckInterval = TimeSpan.FromSeconds(5); @@ -78,7 +78,7 @@ private MultiGroupOptions( /// /// Allows configuration of . /// - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + [Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public sealed class Builder { /// diff --git a/src/StackExchange.Redis/Availability/RetryPolicy.cs b/src/StackExchange.Redis/Availability/RetryPolicy.cs index ada4dd8912..b6af57b6ac 100644 --- a/src/StackExchange.Redis/Availability/RetryPolicy.cs +++ b/src/StackExchange.Redis/Availability/RetryPolicy.cs @@ -12,7 +12,7 @@ namespace StackExchange.Redis.Availability; /// Instances are immutable and safe to share; use to configure the standard policy, or /// derive from this type and override to make the decision yourself. /// -[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +[Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public class RetryPolicy { internal const int DefaultMaxAttempts = 3; @@ -207,7 +207,7 @@ private static Builder Validate(Builder builder) /// /// Allows configuration of the standard implementation. /// - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + [Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public sealed class Builder { /// diff --git a/src/StackExchange.Redis/Availability/RetryResult.cs b/src/StackExchange.Redis/Availability/RetryResult.cs index 404955212d..2b99ed31aa 100644 --- a/src/StackExchange.Redis/Availability/RetryResult.cs +++ b/src/StackExchange.Redis/Availability/RetryResult.cs @@ -8,7 +8,7 @@ namespace StackExchange.Redis.Availability; /// Indicates the result of a query. /// [Flags] -[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +[Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public enum RetryResult { /// diff --git a/src/StackExchange.Redis/ConfigurationOptions.cs b/src/StackExchange.Redis/ConfigurationOptions.cs index 90e90933e1..ff042f0c1c 100644 --- a/src/StackExchange.Redis/ConfigurationOptions.cs +++ b/src/StackExchange.Redis/ConfigurationOptions.cs @@ -1393,7 +1393,7 @@ public RedisProtocol? Protocol /// , else this, else /// . /// - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + [Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public CircuitBreaker? CircuitBreaker { get; set; } /// @@ -1403,7 +1403,7 @@ public RedisProtocol? Protocol /// /// For a member of a connection group, applies instead. /// - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + [Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public RetryPolicy? RetryPolicy { get; set; } internal bool AllowSimulateConnectionFailure diff --git a/src/StackExchange.Redis/Enums/CommandFlags.cs b/src/StackExchange.Redis/Enums/CommandFlags.cs index 510510c6bb..5abde896b8 100644 --- a/src/StackExchange.Redis/Enums/CommandFlags.cs +++ b/src/StackExchange.Redis/Enums/CommandFlags.cs @@ -134,53 +134,53 @@ to slide other things in later. Note that 0 is the implicit "not specified" valu /// /// The command is always safe to retry, regardless of connection or server state. /// - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + [Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] CommandRetryAlways = 1 << 13, // pre-shift value 1 /// /// The command relates to the connection or to safe metadata (for example CLIENT SETNAME, /// COMMAND, or CONFIG GET) and can be retried at the connection level. /// - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + [Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] CommandRetryConnection = 4 << 13, // pre-shift value 4 /// /// The command only reads data (for example GET) and can be safely retried. /// - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + [Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] CommandRetryReadOnly = 8 << 13, // pre-shift value 8 /// /// The command writes data conditionally (for example SETNX or SET ... IFEQ), so a retry /// is checked against server state. /// - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + [Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] CommandRetryWriteChecked = 12 << 13, // pre-shift value 12 /// /// The command writes data such that a retry simply overwrites (last-writer-wins, for example SET). /// - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + [Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] CommandRetryWriteLastWins = 16 << 13, // pre-shift value 16 /// /// The command writes data cumulatively (for example INCR or LPOP), so a retry can /// double-apply and change the result. /// - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + [Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] CommandRetryWriteAccumulating = 20 << 13, // pre-shift value 20 /// /// The command performs server administration (for example REPLICAOF or CONFIG SET); these /// are commonly also endpoint-specific (the internal server-specific flag). /// - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + [Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] CommandRetryServerAdmin = 24 << 13, // pre-shift value 24 /// /// The command should never be retried. /// - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + [Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] CommandRetryNever = 31 << 13, // pre-shift value 31 (the full retry-category region) // 262144 (bit 18): "server specific" - the command is tied to a specific endpoint and must never be diff --git a/src/StackExchange.Redis/Enums/ConnectionFailureType.cs b/src/StackExchange.Redis/Enums/ConnectionFailureType.cs index d771df9db7..9f0df5ba09 100644 --- a/src/StackExchange.Redis/Enums/ConnectionFailureType.cs +++ b/src/StackExchange.Redis/Enums/ConnectionFailureType.cs @@ -66,7 +66,7 @@ public enum ConnectionFailureType /// /// The associated with this connection detected instability. /// - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + [Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] CircuitBreaker, } } diff --git a/src/StackExchange.Redis/Interfaces/IConnectionGroup.cs b/src/StackExchange.Redis/Interfaces/IConnectionGroup.cs index 1d401d7421..0b0b534782 100644 --- a/src/StackExchange.Redis/Interfaces/IConnectionGroup.cs +++ b/src/StackExchange.Redis/Interfaces/IConnectionGroup.cs @@ -13,7 +13,7 @@ namespace StackExchange.Redis.Availability; /// servers, routing traffic based on the availability of the servers and their /// relative . /// -[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +[Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public interface IConnectionGroup : IConnectionMultiplexer { /// @@ -61,7 +61,7 @@ public interface IConnectionGroup : IConnectionMultiplexer /// /// Represents a change to a connection group. /// -[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +[Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public class GroupConnectionChangedEventArgs(GroupConnectionChangedEventArgs.ChangeType type, ConnectionGroupMember group, ConnectionGroupMember? previousGroup = null) : EventArgs, ICompletable { /// diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs index 60005dbddb..f80aa744ac 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs @@ -24,7 +24,7 @@ public partial interface IDatabaseAsync : IRedisAsync /// Unlike , this offers no synchronous /// execution, so it is usable from async-only databases such as one created via /// . - [Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] + [Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] ITransactionAsync CreateTransaction(object? asyncState = null); /// diff --git a/src/StackExchange.Redis/PhysicalConnection.cs b/src/StackExchange.Redis/PhysicalConnection.cs index 80ef8621b6..61c7ad5998 100644 --- a/src/StackExchange.Redis/PhysicalConnection.cs +++ b/src/StackExchange.Redis/PhysicalConnection.cs @@ -1228,7 +1228,7 @@ public void ObserveMessageResult(Exception? fault) // Actuates a pending circuit-breaker teardown at most once. Called from both the trip worker and // the heartbeat backstop; whoever wins the tripped->actuated transition does the work. Routing via // RecordConnectionFailed (not a bare Shutdown) is what surfaces the failure as a ConnectionFailed - // event, which is what Active:Active reacts to when re-routing away from this member. + // event, which is what a connection group reacts to when re-routing away from this member. private void CheckCircuitBreakerTrip() { if (Interlocked.CompareExchange(ref _circuitBreakerState, CircuitBreakerActuated, CircuitBreakerTripped) is CircuitBreakerTripped) diff --git a/src/StackExchange.Redis/RedisErrorKind.cs b/src/StackExchange.Redis/RedisErrorKind.cs index 4fdebf33fd..2a60f76f63 100644 --- a/src/StackExchange.Redis/RedisErrorKind.cs +++ b/src/StackExchange.Redis/RedisErrorKind.cs @@ -12,7 +12,7 @@ namespace StackExchange.Redis; /// message text). Used to classify faults consistently - in particular to decide whether a fault is /// transient (worth retrying / awaiting failover) or permanent (retrying will never help). /// -[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +[Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] public enum RedisErrorKind { /// From 083c4d95b9c06f1de69d2ac097ee071b901164fc Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 30 Jul 2026 13:12:28 +0100 Subject: [PATCH 30/33] we want GA to be 3.1.0 --- version.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.json b/version.json index b4ab1f0283..f556e54c82 100644 --- a/version.json +++ b/version.json @@ -1,7 +1,7 @@ { "version": "3.1", - "versionHeightOffset": 0, - "versionHeightOffsetAppliesTo": "3.0", + "versionHeightOffset": -1, + "versionHeightOffsetAppliesTo": "3.1", "assemblyVersion": "3.0", "publicReleaseRefSpec": [ "^refs/heads/main$", From 445ba5dfd0ac2057a281d67acb2e5d18508bf12d Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 30 Jul 2026 14:33:01 +0100 Subject: [PATCH 31/33] more naming --- docs/Configuration.md | 4 ++-- docs/{GeoRedundantFailover.md => Failover.md} | 6 +++--- docs/exp/SER007.md | 8 ++++---- docs/index.md | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) rename docs/{GeoRedundantFailover.md => Failover.md} (99%) diff --git a/docs/Configuration.md b/docs/Configuration.md index e622ed8af9..c79082fa93 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -139,9 +139,9 @@ Additional code-only options: - ResponseBufferPool (`MemoryPool`) - Default: `null` - The buffer pool to use when buffering responses (and for allocating `Lease` results); when `null`, a shared default pool is used - CircuitBreaker (`CircuitBreaker`) - Default: `null` - - **[Experimental](exp/SER007)** (geo-redundant failover). A per-connection circuit breaker that *passively* observes the outcome of normal traffic and tears the connection down when it becomes unstable. When the connection is a member of a connection group, this flows in from `MultiGroupOptions.CircuitBreaker` if not set explicitly. See [Geo-Redundant Failover](GeoRedundantFailover) + - **[Experimental](exp/SER007)** (client-side geographic failover). A per-connection circuit breaker that *passively* observes the outcome of normal traffic and tears the connection down when it becomes unstable. When the connection is a member of a connection group, this flows in from `MultiGroupOptions.CircuitBreaker` if not set explicitly. See [Client-side geographic failover](Failover) - HealthCheck (`HealthCheck`) - Default: `null` - - **[Experimental](exp/SER007)** (geo-redundant failover). An *active* health check used when the connection is a member of a connection group; when `null`, the group-level `MultiGroupOptions.HealthCheck` is used. See [Geo-Redundant Failover](GeoRedundantFailover) + - **[Experimental](exp/SER007)** (client-side geographic failover). An *active* health check used when the connection is a member of a connection group; when `null`, the group-level `MultiGroupOptions.HealthCheck` is used. See [Client-side geographic failover](Failover) Tokens in the configuration string are comma-separated; any without an `=` sign are assumed to be redis server endpoints. Endpoints without an explicit port will use 6379 if ssl is not enabled, and 6380 if ssl is enabled. Tokens starting with `$` are taken to represent command maps, for example: `$config=cfg`. diff --git a/docs/GeoRedundantFailover.md b/docs/Failover.md similarity index 99% rename from docs/GeoRedundantFailover.md rename to docs/Failover.md index a7e50e5e88..8b7e9b3c67 100644 --- a/docs/GeoRedundantFailover.md +++ b/docs/Failover.md @@ -1,4 +1,4 @@ -# Geo-Redundant Failover +# Client-side geographic failover > **What this is, and what it is called elsewhere.** This feature does one thing: it moves traffic between several independent endpoints that can each serve > your workload, using health checks, circuit breakers and retries to decide when to move. Nothing in it is tied to a particular server topology, which is why @@ -22,7 +22,7 @@ ## Overview -Geo-redundant failover provides automatic failover and intelligent routing across multiple Redis deployments. It is built from several cooperating pieces: +Client-side geographic failover provides automatic failover, failback, and intelligent routing across multiple Redis deployments. It is built from several cooperating pieces: 1. **Connecting to multiple servers or regions at once**, giving a deployment redundant endpoints to fall back on. 2. **Health checks** that *actively* probe each endpoint on a timer to monitor its availability. @@ -98,7 +98,7 @@ This enables scenarios such as: ### Connecting to Multiple Groups -To create a geo-redundant connection, use `ConnectionMultiplexer.ConnectGroupAsync()` with an array of `ConnectionGroupMember` instances: +To create a failover-capable connection, use `ConnectionMultiplexer.ConnectGroupAsync()` with an array of `ConnectionGroupMember` instances: ```csharp // Define your Redis endpoints diff --git a/docs/exp/SER007.md b/docs/exp/SER007.md index b8478f76c6..fd199e85e4 100644 --- a/docs/exp/SER007.md +++ b/docs/exp/SER007.md @@ -1,10 +1,10 @@ -# Geo-Redundant Failover +# Client-side geographic failover This feature is used to fail over between multiple redundant endpoints that can each serve your workload - designed for an **Active:Active** (geo-distributed, bidirectionally-replicated) deployment, but usable over any set of endpoints, including ones with no replication between them (see the full docs for what that means for your data). It is functionally similar to the "multi-DB client" concept in other Redis clients (Jedis' `MultiDbClient`, redis-py's `MultiDBClient`); please see -[full docs](/GeoRedundantFailover). +[full docs](/Failover). ## Status: released and fully supported @@ -13,8 +13,8 @@ unreleased server version, and it does not depend on any server API that is subj bugs, and can be reported and addressed in the usual way. No API changes are currently planned. The `[Experimental]` marker is nonetheless a real one: while it is present, we reserve the right to adjust this API surface -without the usual backwards-compatibility guarantees. That is the whole reason it is here. Geo-redundant failover is a -large, brand-new public API arriving in a single release, and once a surface that size is locked down it is locked down +without the usual backwards-compatibility guarantees. That is the whole reason it is here. Client-side geographic failover +is a large, brand-new public API arriving in a single release, and once a surface that size is locked down it is locked down for good - so we would rather keep room to correct a design wrinkle found in real-world use than commit to every detail on day one. diff --git a/docs/index.md b/docs/index.md index 515d11b4dc..c020ae9b41 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,7 +35,7 @@ Documentation - [Basic Usage](Basics) - getting started and basic usage - [Async Timeouts](AsyncTimeouts) - async timeouts and cancellation - [Configuration](Configuration) - options available when connecting to redis -- [Geo-Redundant Failover](GeoRedundantFailover) (Active:Active / "multi-DB client") - connecting to multiple redundant Redis endpoints for high availability +- [Client-side geographic failover](Failover) (Active:Active / "multi-DB client") - connecting to multiple redundant Redis endpoints for high availability - [Pipelines and Multiplexers](PipelinesMultiplexers) - what is a multiplexer? - [Keys, Values and Channels](KeysValues) - discusses the data-types used on the API - [Transactions](Transactions) - how atomic transactions work in redis From a98e1f89532594a2fb1861c6091356e8b3fb7c0c Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 30 Jul 2026 14:35:40 +0100 Subject: [PATCH 32/33] prefer Redis naming of Active-Active --- docs/Failover.md | 2 +- docs/exp/SER007.md | 2 +- docs/index.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/Failover.md b/docs/Failover.md index 8b7e9b3c67..d3b742555c 100644 --- a/docs/Failover.md +++ b/docs/Failover.md @@ -4,7 +4,7 @@ > your workload, using health checks, circuit breakers and retries to decide when to move. Nothing in it is tied to a particular server topology, which is why > the naming here is about the *behaviour* (failover between redundant endpoints) rather than about any specific deployment. > -> The deployment it is **designed** for is **Active:Active** (geo-distributed, bidirectionally-replicated) Redis - Redis Software / Redis Cloud Active-Active +> The deployment it is **designed** for is **Active-Active** (geo-distributed, bidirectionally-replicated) Redis - Redis Software / Redis Cloud Active-Active > databases, or any other topology where the same logical dataset is reachable through more than one independent endpoint. That is where failover is closest to > transparent: because the members replicate to each other, an operation that lands on the new endpoint still sees what you wrote to the old one. > diff --git a/docs/exp/SER007.md b/docs/exp/SER007.md index fd199e85e4..60fee6dd8f 100644 --- a/docs/exp/SER007.md +++ b/docs/exp/SER007.md @@ -1,7 +1,7 @@ # Client-side geographic failover This feature is used to fail over between multiple redundant endpoints that can each serve your workload - designed for an -**Active:Active** (geo-distributed, bidirectionally-replicated) deployment, but usable over any set of endpoints, including ones +**Active-Active** (geo-distributed, bidirectionally-replicated) deployment, but usable over any set of endpoints, including ones with no replication between them (see the full docs for what that means for your data). It is functionally similar to the "multi-DB client" concept in other Redis clients (Jedis' `MultiDbClient`, redis-py's `MultiDBClient`); please see [full docs](/Failover). diff --git a/docs/index.md b/docs/index.md index c020ae9b41..a49d708b22 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,7 +35,7 @@ Documentation - [Basic Usage](Basics) - getting started and basic usage - [Async Timeouts](AsyncTimeouts) - async timeouts and cancellation - [Configuration](Configuration) - options available when connecting to redis -- [Client-side geographic failover](Failover) (Active:Active / "multi-DB client") - connecting to multiple redundant Redis endpoints for high availability +- [Client-side geographic failover](Failover) (Active-Active / "multi-DB client") - connecting to multiple redundant Redis endpoints for high availability - [Pipelines and Multiplexers](PipelinesMultiplexers) - what is a multiplexer? - [Keys, Values and Channels](KeysValues) - discusses the data-types used on the API - [Transactions](Transactions) - how atomic transactions work in redis From 55f6f018764bf7f4e6dd1f73a9cec45d44074f80 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 31 Jul 2026 07:48:43 +0100 Subject: [PATCH 33/33] - rejigger the declaration order of CommandFlags to make ToString() happy - step up the obsolete member removal --- .../ClusterConfiguration.cs | 2 +- .../ConfigurationOptions.cs | 10 +-- .../ConnectionMultiplexer.Compat.cs | 4 +- .../ConnectionMultiplexer.cs | 4 +- src/StackExchange.Redis/Enums/ClientFlags.cs | 4 +- src/StackExchange.Redis/Enums/ClientType.cs | 2 +- src/StackExchange.Redis/Enums/CommandFlags.cs | 66 +++++++++---------- .../Enums/ReplicationChangeOptions.cs | 2 +- .../Interfaces/IConnectionMultiplexer.cs | 2 +- src/StackExchange.Redis/Interfaces/IServer.cs | 16 ++--- src/StackExchange.Redis/Message.cs | 4 +- src/StackExchange.Redis/RedisFeatures.cs | 2 +- .../StackExchange.Redis.Tests/ConfigTests.cs | 45 +++++++++++-- .../DeprecatedTests.cs | 47 ++++++++----- 14 files changed, 124 insertions(+), 86 deletions(-) diff --git a/src/StackExchange.Redis/ClusterConfiguration.cs b/src/StackExchange.Redis/ClusterConfiguration.cs index c474c3c1be..072c6720f6 100644 --- a/src/StackExchange.Redis/ClusterConfiguration.cs +++ b/src/StackExchange.Redis/ClusterConfiguration.cs @@ -374,7 +374,7 @@ public IList Children /// /// Gets whether this node is a replica. /// - [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(IsReplica) + " instead, this will be removed in 3.0.")] + [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(IsReplica) + " instead, this will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] public bool IsSlave => IsReplica; diff --git a/src/StackExchange.Redis/ConfigurationOptions.cs b/src/StackExchange.Redis/ConfigurationOptions.cs index ff042f0c1c..13affadd59 100644 --- a/src/StackExchange.Redis/ConfigurationOptions.cs +++ b/src/StackExchange.Redis/ConfigurationOptions.cs @@ -351,7 +351,7 @@ public int AsyncTimeout /// /// Indicates whether the connection should be encrypted. /// - [Obsolete("Please use .Ssl instead of .UseSsl, will be removed in 3.0."), + [Obsolete("Please use .Ssl instead of .UseSsl, will be removed in 3.2.", error: true), Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] public bool UseSsl @@ -678,7 +678,7 @@ public TimeSpan HeartbeatInterval /// Use ThreadPriority.AboveNormal for SocketManager reader and writer threads (true by default). /// If , will be used. /// - [Obsolete($"This setting no longer has any effect, please use {nameof(SocketManager.SocketManagerOptions)}.{nameof(SocketManager.SocketManagerOptions.UseHighPrioritySocketThreads)} instead - this setting will be removed in 3.0.")] + [Obsolete($"This setting no longer has any effect, please use {nameof(SocketManager.SocketManagerOptions)}.{nameof(SocketManager.SocketManagerOptions.UseHighPrioritySocketThreads)} instead - this setting will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] public bool HighPrioritySocketThreads { @@ -757,7 +757,7 @@ public string? Password /// /// Specifies whether asynchronous operations should be invoked in a way that guarantees their original delivery order. /// - [Obsolete("Not supported; if you require ordered pub/sub, please see " + nameof(ChannelMessageQueue) + " - this will be removed in 3.0.", false)] + [Obsolete("Not supported; if you require ordered pub/sub, please see " + nameof(ChannelMessageQueue) + " - this will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] public bool PreserveAsyncOrder { @@ -805,7 +805,7 @@ public bool ResolveDns /// /// Specifies the time in milliseconds that the system should allow for responses before concluding that the socket is unhealthy. /// - [Obsolete("This setting no longer has any effect, and should not be used - will be removed in 3.0.")] + [Obsolete("This setting no longer has any effect, and should not be used - will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] public int ResponseTimeout { @@ -885,7 +885,7 @@ public string TieBreaker /// /// The size of the output buffer to use. /// - [Obsolete("This setting no longer has any effect, and should not be used - will be removed in 3.0.")] + [Obsolete("This setting no longer has any effect, and should not be used - will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] public int WriteBuffer { diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.Compat.cs b/src/StackExchange.Redis/ConnectionMultiplexer.Compat.cs index 6786e87d2d..7108141ac8 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.Compat.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.Compat.cs @@ -9,14 +9,14 @@ public partial class ConnectionMultiplexer /// /// No longer used. /// - [Obsolete("No longer used, will be removed in 3.0.")] + [Obsolete("No longer used, will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] public static TaskFactory Factory { get => Task.Factory; set { } } /// /// Gets or sets whether asynchronous operations should be invoked in a way that guarantees their original delivery order. /// - [Obsolete("Not supported; if you require ordered pub/sub, please see " + nameof(ChannelMessageQueue) + ", will be removed in 3.0", false)] + [Obsolete("Not supported; if you require ordered pub/sub, please see " + nameof(ChannelMessageQueue) + ", will be removed in 3.2", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] public bool PreserveAsyncOrder { get => false; set { } } } diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index 44b2957e68..ab704d69eb 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -85,7 +85,7 @@ pulse is null unchecked(Environment.TickCount - Volatile.Read(ref lastGlobalHeartbeatTicks)) / 1000; /// - [Obsolete($"Please use {nameof(ConfigurationOptions)}.{nameof(ConfigurationOptions.IncludeDetailInExceptions)} instead - this will be removed in 3.0.")] + [Obsolete($"Please use {nameof(ConfigurationOptions)}.{nameof(ConfigurationOptions.IncludeDetailInExceptions)} instead - this will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] public bool IncludeDetailInExceptions { @@ -94,7 +94,7 @@ public bool IncludeDetailInExceptions } /// - [Obsolete($"Please use {nameof(ConfigurationOptions)}.{nameof(ConfigurationOptions.IncludePerformanceCountersInExceptions)} instead - this will be removed in 3.0.")] + [Obsolete($"Please use {nameof(ConfigurationOptions)}.{nameof(ConfigurationOptions.IncludePerformanceCountersInExceptions)} instead - this will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] public bool IncludePerformanceCountersInExceptions { diff --git a/src/StackExchange.Redis/Enums/ClientFlags.cs b/src/StackExchange.Redis/Enums/ClientFlags.cs index eb687bba61..a6b86312d6 100644 --- a/src/StackExchange.Redis/Enums/ClientFlags.cs +++ b/src/StackExchange.Redis/Enums/ClientFlags.cs @@ -89,7 +89,7 @@ public enum ClientFlags : long /// /// The client is a replica in MONITOR mode. /// - [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(ReplicaMonitor) + " instead, this will be removed in 3.0.")] + [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(ReplicaMonitor) + " instead, this will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] SlaveMonitor = 1, @@ -101,7 +101,7 @@ public enum ClientFlags : long /// /// The client is a normal replica server. /// - [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(Replica) + " instead, this will be removed in 3.0.")] + [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(Replica) + " instead, this will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] Slave = 2, diff --git a/src/StackExchange.Redis/Enums/ClientType.cs b/src/StackExchange.Redis/Enums/ClientType.cs index c2b003d9a5..0013d4c797 100644 --- a/src/StackExchange.Redis/Enums/ClientType.cs +++ b/src/StackExchange.Redis/Enums/ClientType.cs @@ -22,7 +22,7 @@ public enum ClientType /// /// Replication connections. /// - [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(Replica) + " instead, this will be removed in 3.0.")] + [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(Replica) + " instead, this will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] Slave = 1, diff --git a/src/StackExchange.Redis/Enums/CommandFlags.cs b/src/StackExchange.Redis/Enums/CommandFlags.cs index 5abde896b8..b1b99b672b 100644 --- a/src/StackExchange.Redis/Enums/CommandFlags.cs +++ b/src/StackExchange.Redis/Enums/CommandFlags.cs @@ -8,6 +8,20 @@ namespace StackExchange.Redis /// /// Behaviour markers associated with a given command. /// + /* + WARNING: the *declaration order* of the members below is load-bearing, and is not the obvious/natural order. + + This enum has duplicate values (the obsolete "slave" aliases, and PreferMaster which is zero like None) and + multi-bit values (PreferReplica/DemandReplica occupy a 2-bit region), both of which [Flags] formatting handles + badly. Which of two same-valued names ToString() picks depends on where they land in the *sorted* name/value + arrays that the runtime builds, and that sort is unstable - so it is a function of declaration order, member + count, and runtime. The order here was determined empirically to give sane output on both .NET Framework and + modern .NET (see FormatTests.CommandFlagsFormatting for the expectations). + + Consequence: adding or removing members can silently perturb ToString() for *existing* values. If + CommandFlagsFormatting starts failing after such a change, juggling the declaration order (in particular the + positions of the obsolete PreferSlave/DemandSlave aliases) is the fix, not a change to the values. + */ [Flags] [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1069:Enums values should not be duplicated", Justification = "Compatibility")] public enum CommandFlags @@ -20,7 +34,7 @@ public enum CommandFlags /// /// From 2.0, this flag is not used. /// - [Obsolete("From 2.0, this flag is not used, this will be removed in 3.0.", false)] + [Obsolete("From 2.0, this flag is not used, this will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] HighPriority = 1, @@ -30,65 +44,45 @@ public enum CommandFlags /// FireAndForget = 2, - /// - /// This operation should be performed on the primary if it is available, but read operations may - /// be performed on a replica if no primary is available. This is the default option. - /// - PreferMaster = 0, + // note: the two obsolete aliases below are declared *before* their replacements, deliberately; see the + // warning above the enum - this is what makes ToString() prefer the "replica" names over the legacy ones -#if NET8_0_OR_GREATER /// /// This operation should be performed on the replica if it is available, but will be performed on /// a primary if no replicas are available. Suitable for read operations only. /// - [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(PreferReplica) + " instead, this will be removed in 3.0.")] + [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(PreferReplica) + " instead, this will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] PreferSlave = 8, -#endif /// - /// This operation should only be performed on the primary. - /// - DemandMaster = 4, - -#if !NET8_0_OR_GREATER - /// - /// This operation should be performed on the replica if it is available, but will be performed on - /// a primary if no replicas are available. Suitable for read operations only. + /// This operation should only be performed on a replica. Suitable for read operations only. /// - [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(PreferReplica) + " instead, this will be removed in 3.0.")] + [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(DemandReplica) + " instead, this will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] - PreferSlave = 8, -#endif + DemandSlave = 12, /// - /// This operation should be performed on the replica if it is available, but will be performed on - /// a primary if no replicas are available. Suitable for read operations only. + /// This operation should be performed on the primary if it is available, but read operations may + /// be performed on a replica if no primary is available. This is the default option. /// - PreferReplica = 8, // note: we're using a 2-bit set here, which [Flags] formatting hates; position is doing the best we can for reasonable outcomes here + PreferMaster = 0, -#if NET8_0_OR_GREATER /// - /// This operation should only be performed on a replica. Suitable for read operations only. + /// This operation should only be performed on the primary. /// - [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(DemandReplica) + " instead, this will be removed in 3.0.")] - [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] - DemandSlave = 12, -#endif + DemandMaster = 4, /// - /// This operation should only be performed on a replica. Suitable for read operations only. + /// This operation should be performed on the replica if it is available, but will be performed on + /// a primary if no replicas are available. Suitable for read operations only. /// - DemandReplica = 12, // note: we're using a 2-bit set here, which [Flags] formatting hates; position is doing the best we can for reasonable outcomes here + PreferReplica = 8, // note: we're using a 2-bit set here, which [Flags] formatting hates; position is doing the best we can for reasonable outcomes here -#if !NET8_0_OR_GREATER /// /// This operation should only be performed on a replica. Suitable for read operations only. /// - [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(DemandReplica) + " instead, this will be removed in 3.0.")] - [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] - DemandSlave = 12, -#endif + DemandReplica = 12, // note: we're using a 2-bit set here, which [Flags] formatting hates; position is doing the best we can for reasonable outcomes here // 16: reserved for additional "demand/prefer" options diff --git a/src/StackExchange.Redis/Enums/ReplicationChangeOptions.cs b/src/StackExchange.Redis/Enums/ReplicationChangeOptions.cs index 897ebbb6cf..785ac722fa 100644 --- a/src/StackExchange.Redis/Enums/ReplicationChangeOptions.cs +++ b/src/StackExchange.Redis/Enums/ReplicationChangeOptions.cs @@ -28,7 +28,7 @@ public enum ReplicationChangeOptions /// /// Issue a REPLICAOF to all other known nodes, making this primary of all. /// - [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(ReplicateToOtherEndpoints) + " instead, this will be removed in 3.0.")] + [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(ReplicateToOtherEndpoints) + " instead, this will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] EnslaveSubordinates = 4, diff --git a/src/StackExchange.Redis/Interfaces/IConnectionMultiplexer.cs b/src/StackExchange.Redis/Interfaces/IConnectionMultiplexer.cs index f9a4238d23..617a63f255 100644 --- a/src/StackExchange.Redis/Interfaces/IConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/Interfaces/IConnectionMultiplexer.cs @@ -77,7 +77,7 @@ public interface IConnectionMultiplexer : IDisposable, IAsyncDisposable /// /// Should exceptions include identifiable details? (key names, additional annotations). /// - [Obsolete($"Please use {nameof(ConfigurationOptions)}.{nameof(ConfigurationOptions.IncludeDetailInExceptions)} instead - this will be removed in 3.0.")] + [Obsolete($"Please use {nameof(ConfigurationOptions)}.{nameof(ConfigurationOptions.IncludeDetailInExceptions)} instead - this will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] bool IncludeDetailInExceptions { get; set; } diff --git a/src/StackExchange.Redis/Interfaces/IServer.cs b/src/StackExchange.Redis/Interfaces/IServer.cs index 975b4c8a73..986e82b8c5 100644 --- a/src/StackExchange.Redis/Interfaces/IServer.cs +++ b/src/StackExchange.Redis/Interfaces/IServer.cs @@ -41,7 +41,7 @@ public partial interface IServer : IRedis /// /// Gets whether the connected server is a replica. /// - [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(IsReplica) + " instead, this will be removed in 3.0.")] + [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(IsReplica) + " instead, this will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] bool IsSlave { get; } @@ -53,7 +53,7 @@ public partial interface IServer : IRedis /// /// Explicitly opt in for replica writes on writable replica. /// - [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(AllowReplicaWrites) + " instead, this will be removed in 3.0.")] + [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(AllowReplicaWrites) + " instead, this will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] bool AllowSlaveWrites { get; set; } @@ -399,7 +399,7 @@ public partial interface IServer : IRedis Task LastSaveAsync(CommandFlags flags = CommandFlags.None); /// - [Obsolete("Please use " + nameof(MakePrimaryAsync) + ", this will be removed in 3.0.")] + [Obsolete("Please use " + nameof(MakePrimaryAsync) + ", this will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] void MakeMaster(ReplicationChangeOptions options, TextWriter? log = null); @@ -502,17 +502,17 @@ public partial interface IServer : IRedis void Shutdown(ShutdownMode shutdownMode = ShutdownMode.Default, CommandFlags flags = CommandFlags.None); /// - [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(ReplicaOfAsync) + " instead, this will be removed in 3.0.")] + [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(ReplicaOfAsync) + " instead, this will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] void SlaveOf(EndPoint master, CommandFlags flags = CommandFlags.None); /// - [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(ReplicaOfAsync) + " instead, this will be removed in 3.0.")] + [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(ReplicaOfAsync) + " instead, this will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] Task SlaveOfAsync(EndPoint master, CommandFlags flags = CommandFlags.None); /// - [Obsolete("Please use " + nameof(ReplicaOfAsync) + ", this will be removed in 3.0.")] + [Obsolete("Please use " + nameof(ReplicaOfAsync) + ", this will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] void ReplicaOf(EndPoint master, CommandFlags flags = CommandFlags.None); @@ -767,12 +767,12 @@ public partial interface IServer : IRedis Task[][]> SentinelMastersAsync(CommandFlags flags = CommandFlags.None); /// - [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(SentinelReplicas) + " instead, this will be removed in 3.0.")] + [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(SentinelReplicas) + " instead, this will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] KeyValuePair[][] SentinelSlaves(string serviceName, CommandFlags flags = CommandFlags.None); /// - [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(SentinelReplicasAsync) + " instead, this will be removed in 3.0.")] + [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(SentinelReplicasAsync) + " instead, this will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] Task[][]> SentinelSlavesAsync(string serviceName, CommandFlags flags = CommandFlags.None); diff --git a/src/StackExchange.Redis/Message.cs b/src/StackExchange.Redis/Message.cs index a48fb84f58..0eceec89e3 100644 --- a/src/StackExchange.Redis/Message.cs +++ b/src/StackExchange.Redis/Message.cs @@ -86,9 +86,7 @@ internal const CommandFlags | CommandFlags.DemandReplica | CommandFlags.PreferMaster | CommandFlags.PreferReplica -#pragma warning disable CS0618 // Type or member is obsolete - | CommandFlags.HighPriority -#pragma warning restore CS0618 + | (CommandFlags)1 // CommandFlags.HighPriority; obsolete-as-error, but still tolerated from callers | CommandFlags.FireAndForget | CommandFlags.NoRedirect | CommandFlags.NoScriptCache diff --git a/src/StackExchange.Redis/RedisFeatures.cs b/src/StackExchange.Redis/RedisFeatures.cs index b5233d843e..a2a0c07260 100644 --- a/src/StackExchange.Redis/RedisFeatures.cs +++ b/src/StackExchange.Redis/RedisFeatures.cs @@ -245,7 +245,7 @@ public RedisFeatures(Version version) public bool ScriptingDatabaseSafe => Version.IsAtLeast(v2_8_12); /// - [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(HyperLogLogCountReplicaSafe) + " instead, this will be removed in 3.0.")] + [Obsolete("Starting with Redis version 5, Redis has moved to 'replica' terminology. Please use " + nameof(HyperLogLogCountReplicaSafe) + " instead, this will be removed in 3.2.", error: true)] [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] public bool HyperLogLogCountSlaveSafe => HyperLogLogCountReplicaSafe; diff --git a/tests/StackExchange.Redis.Tests/ConfigTests.cs b/tests/StackExchange.Redis.Tests/ConfigTests.cs index d2f5c3c0bf..00fe6ad1b8 100644 --- a/tests/StackExchange.Redis.Tests/ConfigTests.cs +++ b/tests/StackExchange.Redis.Tests/ConfigTests.cs @@ -730,6 +730,39 @@ public async Task BeforeSocketConnect() Assert.True(subscriptionSocket.DontFragment); } + /// + /// Reads a property that is obsolete-as-error, which the compiler will not let us name directly + /// (and #pragma warning disable cannot suppress, since it is an error rather than a warning). + /// + private static T GetObsoleteProperty(object target, string name) + { + var type = target.GetType(); + var property = type.GetProperty(name); + if (property is null) + { + throw new ArgumentException($"Property '{name}' was not found on '{type.FullName}'; has it been renamed or removed?", nameof(name)); + } + + var value = property.GetValue(target); + if (value is null) + { + // null is a perfectly good value unless the caller asked for a non-nullable value type + if (typeof(T).IsValueType && Nullable.GetUnderlyingType(typeof(T)) is null) + { + throw new ArgumentException($"Property '{type.FullName}.{name}' is null; expected '{typeof(T).Name}'.", nameof(name)); + } + + return default!; + } + + if (value is not T typed) + { + throw new ArgumentException($"Property '{type.FullName}.{name}' is of type '{property.PropertyType.Name}' with value '{value}'; expected '{typeof(T).Name}'.", nameof(name)); + } + + return typed; + } + [Fact] public async Task MutableOptions() { @@ -755,21 +788,21 @@ public async Task MutableOptions() options.CommandMap = CommandMap.Envoyproxy; Assert.NotSame(options.CommandMap, conn.CommandMap); -#pragma warning disable CS0618 // Type or member is obsolete + // note: the ConnectionMultiplexer-level versions of these are [Obsolete(..., error: true)], so they + // can only be reached via reflection - see GetObsoleteProperty // Defaults true Assert.True(options.IncludeDetailInExceptions); - Assert.True(conn.IncludeDetailInExceptions); + Assert.True(GetObsoleteProperty(conn, nameof(options.IncludeDetailInExceptions))); options.IncludeDetailInExceptions = false; Assert.False(options.IncludeDetailInExceptions); - Assert.False(conn.IncludeDetailInExceptions); + Assert.False(GetObsoleteProperty(conn, nameof(options.IncludeDetailInExceptions))); // Defaults false Assert.False(options.IncludePerformanceCountersInExceptions); - Assert.False(conn.IncludePerformanceCountersInExceptions); + Assert.False(GetObsoleteProperty(conn, nameof(options.IncludePerformanceCountersInExceptions))); options.IncludePerformanceCountersInExceptions = true; Assert.True(options.IncludePerformanceCountersInExceptions); - Assert.True(conn.IncludePerformanceCountersInExceptions); -#pragma warning restore CS0618 + Assert.True(GetObsoleteProperty(conn, nameof(options.IncludePerformanceCountersInExceptions))); var newName = Guid.NewGuid().ToString(); options.ClientName = newName; diff --git a/tests/StackExchange.Redis.Tests/DeprecatedTests.cs b/tests/StackExchange.Redis.Tests/DeprecatedTests.cs index ab909ea167..1da30ca4cb 100644 --- a/tests/StackExchange.Redis.Tests/DeprecatedTests.cs +++ b/tests/StackExchange.Redis.Tests/DeprecatedTests.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Reflection; using Xunit; namespace StackExchange.Redis.Tests; @@ -8,63 +9,75 @@ namespace StackExchange.Redis.Tests; /// public class DeprecatedTests(ITestOutputHelper output) : TestBase(output) { -#pragma warning disable CS0618 // Type or member is obsolete + // note: everything under test here is [Obsolete(..., error: true)], so it cannot be named directly - not + // even via nameof, and #pragma cannot suppress an error; reflection is the only way to reach these members + private static PropertyInfo AssertObsoleteAsError(string name) + { + var property = typeof(ConfigurationOptions).GetProperty(name) + ?? throw new MissingMemberException(nameof(ConfigurationOptions), name); + var obsolete = property.GetCustomAttribute(); + Assert.NotNull(obsolete); + Assert.True(obsolete.IsError, $"{name} should be obsolete as an error"); + return property; + } + + private static T Get(PropertyInfo property, ConfigurationOptions options) => (T)property.GetValue(options)!; + [Fact] public void HighPrioritySocketThreads() { - Assert.True(Attribute.IsDefined(typeof(ConfigurationOptions).GetProperty(nameof(ConfigurationOptions.HighPrioritySocketThreads))!, typeof(ObsoleteAttribute))); + var property = AssertObsoleteAsError("HighPrioritySocketThreads"); var options = ConfigurationOptions.Parse("name=Hello"); - Assert.False(options.HighPrioritySocketThreads); + Assert.False(Get(property, options)); options = ConfigurationOptions.Parse("highPriorityThreads=true"); Assert.Equal("", options.ToString()); - Assert.False(options.HighPrioritySocketThreads); + Assert.False(Get(property, options)); options = ConfigurationOptions.Parse("highPriorityThreads=false"); Assert.Equal("", options.ToString()); - Assert.False(options.HighPrioritySocketThreads); + Assert.False(Get(property, options)); } [Fact] public void PreserveAsyncOrder() { - Assert.True(Attribute.IsDefined(typeof(ConfigurationOptions).GetProperty(nameof(ConfigurationOptions.PreserveAsyncOrder))!, typeof(ObsoleteAttribute))); + var property = AssertObsoleteAsError("PreserveAsyncOrder"); var options = ConfigurationOptions.Parse("name=Hello"); - Assert.False(options.PreserveAsyncOrder); + Assert.False(Get(property, options)); options = ConfigurationOptions.Parse("preserveAsyncOrder=true"); Assert.Equal("", options.ToString()); - Assert.False(options.PreserveAsyncOrder); + Assert.False(Get(property, options)); options = ConfigurationOptions.Parse("preserveAsyncOrder=false"); Assert.Equal("", options.ToString()); - Assert.False(options.PreserveAsyncOrder); + Assert.False(Get(property, options)); } [Fact] public void WriteBufferParse() { - Assert.True(Attribute.IsDefined(typeof(ConfigurationOptions).GetProperty(nameof(ConfigurationOptions.WriteBuffer))!, typeof(ObsoleteAttribute))); + var property = AssertObsoleteAsError("WriteBuffer"); var options = ConfigurationOptions.Parse("name=Hello"); - Assert.Equal(0, options.WriteBuffer); + Assert.Equal(0, Get(property, options)); options = ConfigurationOptions.Parse("writeBuffer=8092"); - Assert.Equal(0, options.WriteBuffer); + Assert.Equal(0, Get(property, options)); } [Fact] public void ResponseTimeout() { - Assert.True(Attribute.IsDefined(typeof(ConfigurationOptions).GetProperty(nameof(ConfigurationOptions.ResponseTimeout))!, typeof(ObsoleteAttribute))); + var property = AssertObsoleteAsError("ResponseTimeout"); var options = ConfigurationOptions.Parse("name=Hello"); - Assert.Equal(0, options.ResponseTimeout); + Assert.Equal(0, Get(property, options)); options = ConfigurationOptions.Parse("responseTimeout=1000"); - Assert.Equal(0, options.ResponseTimeout); + Assert.Equal(0, Get(property, options)); } -#pragma warning restore CS0618 }