diff --git a/Directory.Build.props b/Directory.Build.props index 7d2e9c6c5..058d2be9e 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/Directory.Packages.props b/Directory.Packages.props index 88bb94c5e..c5261d0e1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,7 +18,7 @@ - + diff --git a/docs/ActiveActive.md b/docs/ActiveActive.md new file mode 100644 index 000000000..aab277ce8 --- /dev/null +++ b/docs/ActiveActive.md @@ -0,0 +1,946 @@ +# Active:Active + +## Overview + +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; +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) +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 +// 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($" Unhealthy: {member.IsUnhealthy}"); + 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(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 +{ + HealthCheck = 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 +{ + HealthCheck = 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(15); +customHealthCheck.ProbeCount = 5; + +var options = new MultiGroupOptions +{ + HealthCheck = customHealthCheck +}; +``` + +### Health Check Properties + +The `HealthCheck` 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 | + +### 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 + +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 (Default) + +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 3 probes succeed +``` + +### Health Check Behavior + +When a health check fails for a member: +- 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 (subject to `FailbackDelay`) + +### 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 + +## 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 + } +}; +``` + +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 | + +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 + +Use `CircuitBreaker.None` to opt out entirely: + +```csharp +var options = new MultiGroupOptions +{ + CircuitBreaker = CircuitBreaker.None +}; +``` + +## 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. + +```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()); + +// 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"); +``` + +> 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. + +### RetryPolicy settings + +`RetryPolicy` controls how many times, how often, and how far an operation is retried: + +| 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 +{ + MaxAttempts = 5, + RetryDelay = TimeSpan.FromMilliseconds(200), + JitterMax = TimeSpan.FromMilliseconds(100), +}; +IDatabaseAsync db = conn.GetDatabase().WithRetry(policy); +``` + +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 + +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. + +### 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"); + } +} +``` + +## 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/docs/Configuration.md b/docs/Configuration.md index ce3ab93f6..8e51f4146 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/docs/exp/SER004.md b/docs/exp/SER004.md index 91f5d87c4..e1e77968b 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 03e7b7bb4..6e446fed3 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 000000000..7d7f64f5a --- /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 69a4cc70f..c6468534f 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/eng/StackExchange.Redis.Build/AutoDatabaseGenerator.cs b/eng/StackExchange.Redis.Build/AutoDatabaseGenerator.cs new file mode 100644 index 000000000..356e72619 --- /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 dc7984c75..0c2df6f92 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 94d30ef47..8d3091322 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 413d0174d..2137946d8 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/RESPite/PublicAPI/Debug/PublicAPI.Shipped.txt b/src/RESPite/PublicAPI/Debug/PublicAPI.Shipped.txt new file mode 100644 index 000000000..804e940b9 --- /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 412c52862..eb6ae9cbd 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 000000000..dd1130e4c --- /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 6b9798b0e..8acf9429c 100644 --- a/src/RESPite/RESPite.csproj +++ b/src/RESPite/RESPite.csproj @@ -40,6 +40,8 @@ + + diff --git a/src/RESPite/Shared/AsciiHash.Instance.cs b/src/RESPite/Shared/AsciiHash.Instance.cs index 50ac4d245..93b8258ca 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/RESPite/Shared/Experiments.cs b/src/RESPite/Shared/Experiments.cs index 304b1f624..f7347293d 100644 --- a/src/RESPite/Shared/Experiments.cs +++ b/src/RESPite/Shared/Experiments.cs @@ -14,6 +14,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/APITypes/StreamInfo.cs b/src/StackExchange.Redis/APITypes/StreamInfo.cs index 1de0526ec..bef227d73 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/AutoDatabase.cs b/src/StackExchange.Redis/AutoDatabase.cs new file mode 100644 index 000000000..395c5f70c --- /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 new file mode 100644 index 000000000..07e72d3b5 --- /dev/null +++ b/src/StackExchange.Redis/Availability/CircuitBreaker.cs @@ -0,0 +1,353 @@ +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( +#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. + /// + /// 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 +#if NET8_0_OR_GREATER + & TimeProvider is null +#endif + & MinimumNumberOfFailures is DefaultMinimumNumberOfFailures) + && MetricsWindowSize == DefaultMetricsWindowSize) + return DefaultInstance; + + return 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 + TimeProvider, +#endif +#pragma warning restore SA1114 + FailureRateThreshold, + MinimumNumberOfFailures, + MetricsWindowSize); + } + + /// + /// Create a new circuit-breaker instance. + /// + public static implicit operator CircuitBreaker(Builder builder) => builder.Create(); + } + + /// + /// Create an object to collate observations for a connection. + /// + 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() + { + /// + /// Record a message outcome. + /// + /// struct arg here is in case we want to add more things later + public abstract void ObserveResult(in FaultContext fault); + + /// + /// 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. + /// + /// True if the connection is considered healthy. + public abstract bool IsHealthy(); + + /// + /// Discard all accumulated observations, returning to a clean state. + /// + public abstract void Reset(); + + 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 + } + + ObserveResult(in FaultContext.Success); + return false; // never trip through success + } + } + + 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 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 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( +#if NET8_0_OR_GREATER + TimeProvider? timeProvider, +#endif + double failureRateThreshold, + int minimumNumberOfFailures, + TimeSpan metricsWindowSize) + { + _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 void ObserveResult(in FaultContext result) + { + // 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, success: !result.IsFault); + } + + 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(); + } + } + } + } +} diff --git a/src/StackExchange.Redis/Availability/DatabaseExtensions.cs b/src/StackExchange.Redis/Availability/DatabaseExtensions.cs new file mode 100644 index 000000000..9733bf485 --- /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 000000000..4321630af --- /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/HealthCheck.ConnectedProbe.cs b/src/StackExchange.Redis/Availability/HealthCheck.ConnectedProbe.cs new file mode 100644 index 000000000..3850aaa61 --- /dev/null +++ b/src/StackExchange.Redis/Availability/HealthCheck.ConnectedProbe.cs @@ -0,0 +1,23 @@ +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 new file mode 100644 index 000000000..968b4e81e --- /dev/null +++ b/src/StackExchange.Redis/Availability/HealthCheck.Execute.cs @@ -0,0 +1,191 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace StackExchange.Redis.Availability; + +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 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(delay).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/Availability/HealthCheck.HealthCheckProbe.cs b/src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbe.cs new file mode 100644 index 000000000..6789bc891 --- /dev/null +++ b/src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbe.cs @@ -0,0 +1,83 @@ +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 new file mode 100644 index 000000000..82062e830 --- /dev/null +++ b/src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbeContext.cs @@ -0,0 +1,43 @@ +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 new file mode 100644 index 000000000..5e465dae2 --- /dev/null +++ b/src/StackExchange.Redis/Availability/HealthCheck.HealthCheckProbePolicy.cs @@ -0,0 +1,105 @@ +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.PingProbe.cs b/src/StackExchange.Redis/Availability/HealthCheck.PingProbe.cs new file mode 100644 index 000000000..594a1bbeb --- /dev/null +++ b/src/StackExchange.Redis/Availability/HealthCheck.PingProbe.cs @@ -0,0 +1,26 @@ +using System.Threading.Tasks; + +namespace StackExchange.Redis.Availability; + +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/Availability/HealthCheck.StringSetProbe.cs b/src/StackExchange.Redis/Availability/HealthCheck.StringSetProbe.cs new file mode 100644 index 000000000..1a6bd2d2d --- /dev/null +++ b/src/StackExchange.Redis/Availability/HealthCheck.StringSetProbe.cs @@ -0,0 +1,57 @@ +using System; +using System.Buffers; +using System.Threading.Tasks; + +namespace StackExchange.Redis.Availability; + +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); + 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); + } + } + } +} diff --git a/src/StackExchange.Redis/Availability/HealthCheck.cs b/src/StackExchange.Redis/Availability/HealthCheck.cs new file mode 100644 index 000000000..8345ed01b --- /dev/null +++ b/src/StackExchange.Redis/Availability/HealthCheck.cs @@ -0,0 +1,125 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Threading; +using RESPite; + +namespace StackExchange.Redis.Availability; + +/// +/// Describes a health check to perform against instances. +/// +[Experimental(Experiments.ActiveActive, UrlFormat = Experiments.UrlFormat)] +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(5); + ProbeCount = 3; + ProbeTimeout = TimeSpan.FromSeconds(3); + ProbeInterval = TimeSpan.FromMilliseconds(500); + Probe = HealthCheckProbe.Ping; + ProbePolicy = HealthCheckProbePolicy.AllSuccess; + } + + /// + /// 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/Availability/MultiGroupDatabase.Arrays.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Arrays.Async.cs new file mode 100644 index 000000000..299159b43 --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Arrays.Async.cs @@ -0,0 +1,76 @@ +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 new file mode 100644 index 000000000..45405e052 --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Arrays.cs @@ -0,0 +1,74 @@ +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 new file mode 100644 index 000000000..1e8dbc05c --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Async.cs @@ -0,0 +1,66 @@ +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 new file mode 100644 index 000000000..32965a40e --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Geo.Async.cs @@ -0,0 +1,52 @@ +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 new file mode 100644 index 000000000..662c4de3f --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Geo.cs @@ -0,0 +1,50 @@ +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 new file mode 100644 index 000000000..bba401aa8 --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Hashes.Async.cs @@ -0,0 +1,128 @@ +using System; +using System.Threading.Tasks; + +namespace StackExchange.Redis.Availability; + +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/Availability/MultiGroupDatabase.Hashes.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Hashes.cs new file mode 100644 index 000000000..3adf40c7d --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Hashes.cs @@ -0,0 +1,130 @@ +using System; + +namespace StackExchange.Redis.Availability; + +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/Availability/MultiGroupDatabase.HyperLogLog.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.HyperLogLog.Async.cs new file mode 100644 index 000000000..933d0131e --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.HyperLogLog.Async.cs @@ -0,0 +1,25 @@ +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 new file mode 100644 index 000000000..b222deea1 --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.HyperLogLog.cs @@ -0,0 +1,23 @@ +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 new file mode 100644 index 000000000..a944d57f4 --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Keys.Async.cs @@ -0,0 +1,80 @@ +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 new file mode 100644 index 000000000..119536084 --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Keys.cs @@ -0,0 +1,79 @@ +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 new file mode 100644 index 000000000..74cf09843 --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Lists.Async.cs @@ -0,0 +1,79 @@ +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 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 new file mode 100644 index 000000000..fad08222d --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Lists.cs @@ -0,0 +1,77 @@ +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 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.Sets.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Sets.Async.cs new file mode 100644 index 000000000..90341cb36 --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Sets.Async.cs @@ -0,0 +1,64 @@ +using System.Threading.Tasks; + +namespace StackExchange.Redis.Availability; + +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/Availability/MultiGroupDatabase.Sets.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Sets.cs new file mode 100644 index 000000000..443039b48 --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Sets.cs @@ -0,0 +1,65 @@ +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 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 new file mode 100644 index 000000000..e89cb3ae6 --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.SortedSets.Async.cs @@ -0,0 +1,127 @@ +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 new file mode 100644 index 000000000..2e237dae8 --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.SortedSets.cs @@ -0,0 +1,128 @@ +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 new file mode 100644 index 000000000..bef1a96cd --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Streams.Async.cs @@ -0,0 +1,137 @@ +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 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/Availability/MultiGroupDatabase.Streams.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Streams.cs new file mode 100644 index 000000000..e10c847fc --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Streams.cs @@ -0,0 +1,136 @@ +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 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/Availability/MultiGroupDatabase.Strings.Async.cs b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Strings.Async.cs new file mode 100644 index 000000000..5e4447b3b --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Strings.Async.cs @@ -0,0 +1,125 @@ +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 new file mode 100644 index 000000000..b29d4a771 --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.Strings.cs @@ -0,0 +1,124 @@ +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 new file mode 100644 index 000000000..b72b6ced1 --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.VectorSets.Async.cs @@ -0,0 +1,55 @@ +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 new file mode 100644 index 000000000..e7a4164db --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.VectorSets.cs @@ -0,0 +1,53 @@ +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 new file mode 100644 index 000000000..7bfc18033 --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupDatabase.cs @@ -0,0 +1,115 @@ +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, 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); + + // 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); + + // 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) + => TryGetActiveDatabase()?.IdentifyEndpoint(key, flags); + + 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); + + 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/Availability/MultiGroupMultiplexer.cs b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs new file mode 100644 index 000000000..4ff4eb872 --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs @@ -0,0 +1,1388 @@ +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; + + // 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 + Unhealthy = 1 << 4, // disabled by a health-check or circuit-breaker + } + + 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; + + private ConnectionMultiplexer? _muxer; + + 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."); + + // 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 (atomic test-and-set of the Activated flag) + while (true) + { + 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 => 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. + /// + 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: 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 + { + get => GetFlag(MemberFlags.ExplicitOverrideFlag); + set => SetFlag(MemberFlags.ExplicitOverrideFlag, value); + } + + private double _weight = 1.0; + + /// + /// The measured latency to this member. + /// + 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; + + 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, ConnectionMultiplexer? active) + { + 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; + 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, long failbackFailureCutoffTicks) + { + bool isConnected; + if (_muxer is { IsConnected: true } muxer) + { + isConnected = result is not HealthCheck.HealthCheckResult.Unhealthy; + SetLatency(muxer.UpdateLatency()); + } + else + { + isConnected = false; + } + + if (isConnected) + { + if (Volatile.Read(ref _lastUnhealthyTicks) < failbackFailureCutoffTicks) ResetIsUnhealthy(); + } + else + { + SetUnhealthy(); + } + + 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 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 = _activeStub.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 _activeStub.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() => _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(_activeStub.Active); + + private ConnectionGroupMember? GetMember(ConnectionMultiplexer? muxer) + { + if (muxer is not null) + { + foreach (var member in _members) + { + if (ReferenceEquals(muxer, 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; + SetActive(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++) + { + // 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(); + var failbackFailureCutoff = GetFailbackFailureCutoff(); + 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, failbackFailureCutoff); + if (delta != GroupConnectionChangedEventArgs.ChangeType.Unknown) + { + OnConnectionChanged(delta, members[i]); + } + } + + 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 existingActive = _activeStub.Active; + ConnectionGroupMember? preferredMember = null, previousMember = null; + var members = _members; + foreach (var member in members) + { + if (previousMember is null && ReferenceEquals(member.Multiplexer, existingActive)) + { + previousMember = member; + } + + if (member.ConsiderActive) + { + member.UpdateLatency(); // this can change passively + + // (note that when in doubt, we prefer the active muxer, to prevent flapping) + preferredMember = ConnectionGroupMember.Select(preferredMember, member, existingActive); + } + } + + SetActive(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) + SetActive(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 => _activeStub.Active?.IsConnected ?? false; + public bool IsConnecting => _activeStub.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) + { + GetMember(sender as ConnectionMultiplexer)?.SetUnhealthy(); + _ = 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.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); + + // 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, GetFailbackFailureCutoff()); + } + + // 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; + } + + member.ResetIsUnhealthy(); // the user explicitly asked us to consider this node + 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/Availability/MultiGroupOptions.cs b/src/StackExchange.Redis/Availability/MultiGroupOptions.cs new file mode 100644 index 000000000..779a1b6fd --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupOptions.cs @@ -0,0 +1,71 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Threading; +using RESPite; + +namespace StackExchange.Redis.Availability; + +/// +/// 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); + } + + /// + /// 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); + } + + /// + /// 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 = "") + { + 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/Availability/MultiGroupSubscriber.Tracking.cs b/src/StackExchange.Redis/Availability/MultiGroupSubscriber.Tracking.cs new file mode 100644 index 000000000..a029eef56 --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupSubscriber.Tracking.cs @@ -0,0 +1,251 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace StackExchange.Redis.Availability; + +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._activeStub.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._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 + // 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); + } + } + + // 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); + } + + 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/Availability/MultiGroupSubscriber.cs b/src/StackExchange.Redis/Availability/MultiGroupSubscriber.cs new file mode 100644 index 000000000..4dbb8e9e3 --- /dev/null +++ b/src/StackExchange.Redis/Availability/MultiGroupSubscriber.cs @@ -0,0 +1,45 @@ +using System; +using System.Net; +using System.Threading.Tasks; + +namespace StackExchange.Redis.Availability; + +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); + + // 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); + + 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) => + TryGetActiveSubscriber()?.IdentifyEndpoint(channel, flags); + + public Task IdentifyEndpointAsync(RedisChannel channel, CommandFlags flags = CommandFlags.None) => + TryGetActiveSubscriber()?.IdentifyEndpointAsync(channel, flags) ?? MultiGroupMultiplexer.NoEndpoint; + + 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); + + public Task PublishAsync(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) + => GetActiveSubscriber().PublishAsync(channel, message, flags); + + public EndPoint? SubscribedEndpoint(RedisChannel channel) => TryGetActiveSubscriber()?.SubscribedEndpoint(channel); +} diff --git a/src/StackExchange.Redis/Availability/RetryDatabase.cs b/src/StackExchange.Redis/Availability/RetryDatabase.cs new file mode 100644 index 000000000..8dfa132a1 --- /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 000000000..447424b2f --- /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/ChannelMessageQueue.cs b/src/StackExchange.Redis/ChannelMessageQueue.cs index 7defc35cc..4171ed4b4 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 eef0bde81..ee6cd6f32 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 @@ -44,6 +45,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 +890,7 @@ public string TieBreaker public int WriteBuffer { get => 0; + // ReSharper disable once ValueParameterNotUsed set { } } @@ -971,6 +979,8 @@ public static ConfigurationOptions Parse(string configuration, bool ignoreUnknow _protocol = _protocol, heartbeatInterval = heartbeatInterval, WriteMode = WriteMode, + CircuitBreaker = CircuitBreaker, + HealthCheck = HealthCheck, #if DEBUG OutputLog = OutputLog, #endif @@ -1174,6 +1184,8 @@ private void Clear() Tunnel = null; _protocol = default; WriteMode = default; + CircuitBreaker = null; + HealthCheck = null; #if DEBUG OutputLog = null; #endif @@ -1372,6 +1384,21 @@ 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. + /// + [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 { get => IsSet(OptionFlags.AllowSimulateConnectionFailure); diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.Sentinel.cs b/src/StackExchange.Redis/ConnectionMultiplexer.Sentinel.cs index 6f11fa2f3..658f6920c 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 d8e328987..86fd7783d 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) ?? []; @@ -1360,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); } @@ -2034,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")] @@ -2362,5 +2364,35 @@ private Task[] QuitAllServers() long? IInternalConnectionMultiplexer.GetConnectionId(EndPoint endpoint, ConnectionType type) => GetServerEndPoint(endpoint)?.GetBridge(type)?.ConnectionId; + + 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) + { + 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/Enums/CommandFlags.Category.cs b/src/StackExchange.Redis/Enums/CommandFlags.Category.cs new file mode 100644 index 000000000..02abc3b8b --- /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 ff69fb750..510510c6b 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/Enums/ConnectionFailureType.cs b/src/StackExchange.Redis/Enums/ConnectionFailureType.cs index 55eeacef6..d771df9db 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/Enums/RedisCommand.cs b/src/StackExchange.Redis/Enums/RedisCommand.cs index a355c02e6..3c6697898 100644 --- a/src/StackExchange.Redis/Enums/RedisCommand.cs +++ b/src/StackExchange.Redis/Enums/RedisCommand.cs @@ -313,6 +313,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/ExceptionFactory.cs b/src/StackExchange.Redis/ExceptionFactory.cs index 380ac0c0f..8428d41af 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 1f1c973ce..405a0ea4e 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/IConnectionGroup.cs b/src/StackExchange.Redis/Interfaces/IConnectionGroup.cs new file mode 100644 index 000000000..e15f951af --- /dev/null +++ b/src/StackExchange.Redis/Interfaces/IConnectionGroup.cs @@ -0,0 +1,125 @@ +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.Availability; + +/// +/// 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. + /// This will implicitly include . + 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/IConnectionMultiplexer.cs b/src/StackExchange.Redis/Interfaces/IConnectionMultiplexer.cs index 96b4ce8f6..f9a4238d2 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 c0761b9a7..dbaa70d42 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 40db55cfd..b7f43848b 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 000000000..7ac83afda --- /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/Interfaces/IServer.cs b/src/StackExchange.Redis/Interfaces/IServer.cs index 1959f15d8..975b4c8a7 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/KeyNotification.cs b/src/StackExchange.Redis/KeyNotification.cs index e52b8ead5..d726d750c 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/KeyspaceIsolation/KeyPrefixed.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.cs index 952fe0ed3..877ffb0b5 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 6f5679a66..710e8cabd 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedBatch.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedBatch.cs @@ -1,8 +1,15 @@ -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) + { + } + + 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 2ca7ca384..50422bcf9 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,13 +11,19 @@ public KeyPrefixedDatabase(IDatabase inner, byte[] prefix) : base(inner, prefix) { } - public IBatch CreateBatch(object? asyncState = null) => - new KeyPrefixedBatch(Inner.CreateBatch(asyncState), Prefix); - - public ITransaction CreateTransaction(object? asyncState = null) => - new KeyPrefixedTransaction(Inner.CreateTransaction(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 int Database => Inner.Database; + 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 89703ba6a..139f50267 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedTransaction.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedTransaction.cs @@ -1,10 +1,16 @@ 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) + { + } + + 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 17ded6c20..b36f3e082 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; @@ -483,18 +495,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(); } @@ -596,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) @@ -693,7 +714,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 +727,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/PhysicalBridge.cs b/src/StackExchange.Redis/PhysicalBridge.cs index 21047efa2..a1ac6e2cf 100644 --- a/src/StackExchange.Redis/PhysicalBridge.cs +++ b/src/StackExchange.Redis/PhysicalBridge.cs @@ -105,9 +105,9 @@ 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 => Interlocked.Read(ref operationCount); + internal long OperationCount => Volatile.Read(ref operationCount); public RedisCommand LastCommand { get; private set; } @@ -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; } @@ -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.WriterCount = Interlocked.CompareExchange(ref activeWriters, 0, 0); - counters.NonPreferredEndpointCount = Interlocked.Read(ref nonPreferredEndpointCount); + counters.SocketCount = Volatile.Read(ref socketCount); + counters.WriterCount = Volatile.Read(ref activeWriters); + 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, @@ -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); } } @@ -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,9 +572,9 @@ internal void OnHeartbeat(bool ifConnectedOnly) if (!runThisTime) return; uint index = (uint)Interlocked.Increment(ref profileLogIndex); - long newSampleCount = Interlocked.Read(ref operationCount); - Interlocked.Exchange(ref profileLog[index % ProfileLogSamples], newSampleCount); - Interlocked.Exchange(ref profileLastLog, newSampleCount); + long newSampleCount = Volatile.Read(ref operationCount); + 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; } } @@ -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; } @@ -1354,8 +1354,8 @@ 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); + 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 // 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 0de5487cd..ad6973eca 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) @@ -1097,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)"); } } @@ -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.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 + // 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.Shipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt index 7e8381c6e..865cea8f5 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt @@ -2355,7 +2355,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 @@ -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 7dc5c5811..47cf16bcf 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,193 @@ -#nullable enable +#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 +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 +[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.CircuitBreaker() -> void +[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.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 +[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]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! +[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/PublicAPI/net6.0/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Shipped.txt index bec516e5c..cec3e2742 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 new file mode 100644 index 000000000..ab058de62 --- /dev/null +++ b/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/StackExchange.Redis/RedisBatch.cs b/src/StackExchange.Redis/RedisBatch.cs index beda224af..5b9e527ea 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() { @@ -116,13 +124,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/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index 21518eaa7..aadaf5abe 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 000000000..4fdebf33f --- /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 78ed649bc..1d496468f 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/RedisServer.cs b/src/StackExchange.Redis/RedisServer.cs index 61918347f..86ffa894a 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, Format.ToString(endpoint).AsRedisValue()); diff --git a/src/StackExchange.Redis/RedisTransaction.cs b/src/StackExchange.Redis/RedisTransaction.cs index 0c369e988..c1c39b3c1 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); @@ -237,17 +242,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 +438,7 @@ public IEnumerable GetMessages(PhysicalConnection connection) { var inner = op.Wrapped; inner.Cancel(); - inner.Complete(); + inner.Complete(connection); } } connection.Trace("End of transaction: " + Command); @@ -479,12 +485,13 @@ 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); - inner.Complete(); + ServerFail(inner, errorKind, error); + inner.Complete(connection); } } return base.SetResult(connection, message, ref copy); @@ -508,7 +515,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 +546,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 +560,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/RespReaderExtensions.cs b/src/StackExchange.Redis/RespReaderExtensions.cs index 2107d0397..5683208eb 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 diff --git a/src/StackExchange.Redis/ResultBox.cs b/src/StackExchange.Redis/ResultBox.cs index 20b76ba15..160493cc1 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/ResultProcessor.Literals.cs b/src/StackExchange.Redis/ResultProcessor.Literals.cs index 7d50cc09e..3deaa7027 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 14acb54c0..45a09cfbc 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(); @@ -3152,38 +3154,33 @@ 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); 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/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs index f191c1c3b..202eaf876 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 @@ -1144,6 +1145,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 000000000..4cbd4538c --- /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 5025b732d..80c9b9efe 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; @@ -52,9 +52,9 @@ internal sealed 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; @@ -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 705b07eec..8d5f585ba 100644 --- a/src/StackExchange.Redis/StackExchange.Redis.csproj +++ b/src/StackExchange.Redis/StackExchange.Redis.csproj @@ -60,4 +60,16 @@ + + + + MultiGroupDatabase.cs + + + HealthCheck.cs + + + MultiGroupSubscriber.cs + + \ No newline at end of file diff --git a/src/StackExchange.Redis/TaskExtensions.cs b/src/StackExchange.Redis/TaskExtensions.cs index 989c0861e..344dff75c 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/src/StackExchange.Redis/ValueCondition.cs b/src/StackExchange.Redis/ValueCondition.cs index 7516e74c0..4164956e5 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/ActiveActiveIntegrationTests.cs b/tests/StackExchange.Redis.Tests/ActiveActiveIntegrationTests.cs new file mode 100644 index 000000000..406999a27 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ActiveActiveIntegrationTests.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading.Tasks; +using StackExchange.Redis.Availability; +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/BufferedStreamWriterTests.cs b/tests/StackExchange.Redis.Tests/BufferedStreamWriterTests.cs index 932153db0..e1c9bceeb 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/CircuitBreakerServerTests.cs b/tests/StackExchange.Redis.Tests/CircuitBreakerServerTests.cs new file mode 100644 index 000000000..81ca78ee0 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/CircuitBreakerServerTests.cs @@ -0,0 +1,88 @@ +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; + + // 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 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); + 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 + // 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 void ObserveResult(in FaultContext context) + { + if (context.IsFault) + { + Interlocked.Increment(ref owner._failures); + owner.LastFault = context.Fault; + } + else + { + Interlocked.Increment(ref owner._successes); + } + } + + 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 000000000..ab0b15545 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/CircuitBreakerUnitTests.cs @@ -0,0 +1,193 @@ +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(CommandFlags.None, "boom", CommandStatus.Unknown))); + } + +#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 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) + => new CircuitBreaker.Builder + { + FailureRateThreshold = failureRateThreshold, + MinimumNumberOfFailures = minimumNumberOfFailures, + MetricsWindowSize = TimeSpan.FromSeconds(10), + 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() => Volatile.Read(ref _timestamp); + + public void Advance(TimeSpan by) => Interlocked.Add(ref _timestamp, by.Ticks); + } +#endif + + private static RedisTimeoutException Timeout() => new(CommandFlags.None, "timeout", CommandStatus.Unknown); + + private static bool Record(CircuitBreaker.Accumulator accumulator, int count, Exception? fault = null) + { + // 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++) + { + accumulator.Trip(fault); + } + + return accumulator.IsHealthy(); + } +} diff --git a/tests/StackExchange.Redis.Tests/ClusterTests.cs b/tests/StackExchange.Redis.Tests/ClusterTests.cs index 118e0f975..af58346e9 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/ConfigTests.cs b/tests/StackExchange.Redis.Tests/ConfigTests.cs index 89ffd851e..83c02bbcb 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", @@ -79,6 +80,7 @@ orderby name "defaultOptions", "defaultVersion", "EndPoints", + "HealthCheck", "heartbeatInterval", "keepAlive", "LibraryName", diff --git a/tests/StackExchange.Redis.Tests/ConnectionShutdownTests.cs b/tests/StackExchange.Redis.Tests/ConnectionShutdownTests.cs index 6fdc90cea..c05ecdec9 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/ControllableProbe.cs b/tests/StackExchange.Redis.Tests/ControllableProbe.cs new file mode 100644 index 000000000..4d8e47dc7 --- /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/FailoverTests.cs b/tests/StackExchange.Redis.Tests/FailoverTests.cs index e1222394c..da470631c 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,17 +339,17 @@ 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 { - 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()) { @@ -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/HashTagUnitTests.cs b/tests/StackExchange.Redis.Tests/HashTagUnitTests.cs new file mode 100644 index 000000000..91b63d980 --- /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 000000000..1bbcebb4e --- /dev/null +++ b/tests/StackExchange.Redis.Tests/HealthCheckPolicyUnitTests.cs @@ -0,0 +1,108 @@ +using System; +using Xunit; +using static StackExchange.Redis.Availability.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 c0194d5a6..59520eab4 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 7dcc5f7c9..2b14692be 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) @@ -403,4 +403,18 @@ 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, in RedisRequest request) + { + var latency = _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/KeyPrefixedDatabaseTests.cs b/tests/StackExchange.Redis.Tests/KeyPrefixedDatabaseTests.cs index ac8d6b05d..3182417a5 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/LockingTests.cs b/tests/StackExchange.Redis.Tests/LockingTests.cs index 52d03bb83..52d498a25 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 31ed3fae0..6839eea5e 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/MultiGroupTests/BasicMultiGroupTests.cs b/tests/StackExchange.Redis.Tests/MultiGroupTests/BasicMultiGroupTests.cs new file mode 100644 index 000000000..a99fcd724 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/MultiGroupTests/BasicMultiGroupTests.cs @@ -0,0 +1,392 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Threading.Tasks; +using StackExchange.Redis.Availability; +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()) + { + member.UpdateLatency(); + 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); + + static ConfigurationOptions Check(ConfigurationOptions options) + { + // options.HeartbeatConsistencyChecks = true; + return options; + } + ConnectionGroupMember[] members = [ + 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}"); + + 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); + + // select lowest latency + 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/MultiGroupTests/CircuitBreakerRerouteTests.cs b/tests/StackExchange.Redis.Tests/MultiGroupTests/CircuitBreakerRerouteTests.cs new file mode 100644 index 000000000..abd442e60 --- /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) : 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 + // 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(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(); + + // 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); + + // 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) + { + circuitBreakerEvents.TrySetResult(true); + } + }; + + // 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($"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 + + // 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( + 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 + { + // 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() { } + } + } +} diff --git a/tests/StackExchange.Redis.Tests/PubSubTests.cs b/tests/StackExchange.Redis.Tests/PubSubTests.cs index 65e1ee574..b4ba7b5de 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] 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 78036e635..b3ea620f5 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/RedisTestConfig.json b/tests/StackExchange.Redis.Tests/RedisTestConfig.json index c652a4583..31ae07818 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/ResultBoxTests.cs b/tests/StackExchange.Redis.Tests/ResultBoxTests.cs index adb1b309f..99c8358e8 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); diff --git a/tests/StackExchange.Redis.Tests/RetryTests/CommandRetryPolicyUnitTests.cs b/tests/StackExchange.Redis.Tests/RetryTests/CommandRetryPolicyUnitTests.cs new file mode 100644 index 000000000..b8e630da1 --- /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 000000000..e2b9583f7 --- /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/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj b/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj index c14c149d6..f20a2646a 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/tests/StackExchange.Redis.Tests/WithKeyPrefixTests.cs b/tests/StackExchange.Redis.Tests/WithKeyPrefixTests.cs index acbef74cf..b70d215ee 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); diff --git a/toys/StackExchange.Redis.Server/MemoryCacheRedisServer.cs b/toys/StackExchange.Redis.Server/MemoryCacheRedisServer.cs index a8c0c22d1..62c8989b6 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 a46705c22..77ad4f1ed 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) { @@ -352,7 +362,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 +441,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 +997,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); } - protected virtual void Set(int database, in RedisKey key, in RedisValue value) => throw new NotSupportedException(); + + [Flags] + public enum SetFlags + { + None = 0, + NX = 1, + XX = 2, + } + + 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 51996721e..cb98ead2b 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, request); 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, in RedisRequest request) => default; + protected object ServerSyncLock => this; private long _totalCommandsProcesed, _totalErrorCount; @@ -427,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 4151146bf..fed77b54c 100644 --- a/toys/StackExchange.Redis.Server/StackExchange.Redis.Server.csproj +++ b/toys/StackExchange.Redis.Server/StackExchange.Redis.Server.csproj @@ -15,5 +15,6 @@ + diff --git a/version.json b/version.json index 461b0972f..b4ab1f028 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",