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.rulesetNETSDK1069
- $(NoWarn);NU5105;NU1507;SER001;SER002;SER003;SER004;SER005;SER006
+ $(NoWarn);NU5105;NU1507;SER001;SER002;SER003;SER004;SER005;SER006;SER007https://stackexchange.github.io/StackExchange.Redis/ReleaseNoteshttps://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