Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ Additional code-only options:
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`.

A single configuration string can also describe a multi-group topology for [client-side geographic failover](Failover), by separating each group with a bare `|` token (`,|,`) and (optionally) tagging each group with `weight=` and `member=`. See [Connecting from a configuration string](Failover#connecting-from-a-configuration-string).

## Obsolete Configuration Options
---

Expand Down
35 changes: 35 additions & 0 deletions docs/Failover.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,41 @@ ConnectionGroupMember[] members = [
await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(members);
```

### Connecting from a configuration string

Where your infrastructure already hands StackExchange.Redis a single connection string, you can express a whole multi-group topology in that one string, rather than building `ConnectionGroupMember` instances in code. Each group is written exactly like an ordinary [configuration string](Configuration), and groups are separated by a **bare `|` token** - i.e. a `|` on its own between commas (`,|,`):

```csharp
await using var conn = await ConnectionMultiplexer.ConnectGroupAsync(
"east-1.example.com,east-2.example.com,password=pA,weight=10,member=US East" +
",|," +
"west-1.example.com,password=pB,weight=5,member=US West");
```

Two additional per-member keys are recognised inside each group:

| Key | Maps to | Notes |
|-----|---------|-------|
| `weight={number}` | `ConnectionGroupMember.Weight` | Relative preference; higher is preferred. Floating-point; defaults to `1`. |
| `member={name}` | `ConnectionGroupMember.Name` | A display name for the member, used in logging/diagnostics. |

All the usual per-connection keys (`password`, `ssl`, `user`, `defaultDatabase`, command maps, ...) apply **per group**, so each region can have its own password, TLS settings, and so on.

> **Why `,|,` and not `...|...`?** The `|` is only treated as a group separator when it stands alone as a comma-delimited token. A `|` that is glued to other text, or that appears *inside* a value (for example a password containing `|`, or `sslProtocols=Tls12|Tls13`), is **never** a separator. This is deliberate: it means the multi-group syntax does not change the existing value-escaping rules at all. (As always, a value cannot contain a comma.) A leading `,|,`/`|` is allowed as an explicit "this is multi-group" marker and does not create an empty group.

`ConnectGroup(string, ...)` is the synchronous companion (sync-over-async over the connect, consistent with `Connect`).

#### Transparent single-or-multi: `ConnectAny`

`ConnectGroupAsync`/`ConnectGroup` return an `IConnectionGroup`; the ordinary `Connect`/`ConnectAsync` return a `ConnectionMultiplexer`. If you have a single code path that is handed a connection string and does not know up front whether it describes one server or a multi-group topology, use `ConnectAny`/`ConnectAnyAsync`. It inspects the string, dispatches to the group path when it finds a `|` separator (and to an ordinary connection otherwise), and returns the shared `IConnectionMultiplexer` abstraction in both cases:

```csharp
// works for BOTH "host:6379,password=..." and "east,...|west,..."
IConnectionMultiplexer conn = await ConnectionMultiplexer.ConnectAnyAsync(configurationString);
```

Passing a multi-group string to the single-connection `Connect`/`ConnectAsync` (or to `ConfigurationOptions.Parse`) throws, rather than silently merging every group's endpoints into one pool - use `ConnectGroupAsync`/`ConnectAny` for those.

## Configuring Weights

Weights allow you to express preference for specific endpoints. Higher weights are preferred when multiple endpoints are available:
Expand Down
87 changes: 87 additions & 0 deletions src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,93 @@ public static Task<IConnectionGroup> ConnectGroupAsync(
{
return MultiGroupMultiplexer.ConnectAsync([member0, member1], options ?? MultiGroupOptions.Default, log);
}

/// <summary>
/// Creates a new <see cref="IConnectionMultiplexer"/> instance that manages connections to multiple
/// redundant configurations parsed from a single multi-group connection string. Groups are separated by a
/// bare <c>|</c> token; per-member <c>weight=</c> and <c>member=</c> (name) may be specified within each group,
/// for example: <c>east:6379,password=a,weight=10,member=US East|west:6379,password=b,weight=5,member=US West</c>.
/// </summary>
/// <param name="configuration">The multi-group configuration string to parse.</param>
/// <param name="options">Additional options for configuring this group.</param>
/// <param name="log">The <see cref="TextWriter"/> to log to.</param>
[Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)]
#pragma warning disable RS0026
public static Task<IConnectionGroup> ConnectGroupAsync(
string configuration,
MultiGroupOptions? options = null,
TextWriter? log = null)
#pragma warning restore RS0026
=> ConnectGroupAsync(ParseGroupMembers(configuration), options, log);

/// <summary>
/// Creates a new <see cref="IConnectionMultiplexer"/> instance that manages connections to multiple
/// redundant configurations parsed from a single multi-group connection string; see
/// <see cref="ConnectGroupAsync(string, MultiGroupOptions?, TextWriter?)"/>. This is a synchronous wrapper
/// (sync-over-async) over the connect, consistent with <see cref="Connect(string, TextWriter?)"/>.
/// </summary>
/// <param name="configuration">The multi-group configuration string to parse.</param>
/// <param name="options">Additional options for configuring this group.</param>
/// <param name="log">The <see cref="TextWriter"/> to log to.</param>
[Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)]
#pragma warning disable RS0026
public static IConnectionGroup ConnectGroup(
string configuration,
MultiGroupOptions? options = null,
TextWriter? log = null)
#pragma warning restore RS0026
=> ConnectGroupAsync(configuration, options, log).GetAwaiter().GetResult();

/// <summary>
/// Connect to either a single server/configuration or a multi-group (Active-Active) configuration, choosing
/// automatically based on whether <paramref name="configuration"/> contains a group delimiter ('|'). The
/// common <see cref="IConnectionMultiplexer"/> abstraction is returned in both cases, so callers whose
/// infrastructure supplies a single connection string can opt into multi-group transparently, without
/// knowing up front which form the string takes.
/// </summary>
/// <param name="configuration">The (single- or multi-group) configuration string to parse.</param>
/// <param name="log">The <see cref="TextWriter"/> to log to.</param>
[Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)]
public static async Task<IConnectionMultiplexer> ConnectAnyAsync(string configuration, TextWriter? log = null)
=> ConfigurationOptions.IsMultiGroup(configuration)
? await ConnectGroupAsync(configuration, log: log).ForAwait()
: await ConnectAsync(configuration, log).ForAwait();

/// <summary>
/// Connect to either a single server/configuration or a multi-group (Active-Active) configuration; the
/// synchronous companion to <see cref="ConnectAnyAsync(string, TextWriter?)"/> (the multi-group path uses
/// sync-over-async, consistent with <see cref="Connect(string, TextWriter?)"/>).
/// </summary>
/// <param name="configuration">The (single- or multi-group) configuration string to parse.</param>
/// <param name="log">The <see cref="TextWriter"/> to log to.</param>
[Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)]
public static IConnectionMultiplexer ConnectAny(string configuration, TextWriter? log = null)
{
if (ConfigurationOptions.IsMultiGroup(configuration))
{
return ConnectGroup(configuration, log: log);
}
return Connect(configuration, log);
}

// parse a multi-group connection string into its members, applying per-member weight/name
private static ConnectionGroupMember[] ParseGroupMembers(string configuration)
{
var groups = ConfigurationOptions.SplitGroups(configuration)
?? throw new ArgumentException(
$"The configuration does not define multiple groups; a group delimiter ('{ConfigurationOptions.GroupDelimiter}') is required. For a single connection, use Connect/ConnectAsync instead.",
nameof(configuration));

var members = new ConnectionGroupMember[groups.Count];
for (int i = 0; i < groups.Count; i++)
{
var config = groups[i];
var member = new ConnectionGroupMember(config, config.MemberName ?? "");
if (config.MemberWeight is double weight) member.Weight = weight;
members[i] = member;
}
return members;
}
}

#pragma warning disable SA1403
Expand Down
115 changes: 114 additions & 1 deletion src/StackExchange.Redis/ConfigurationOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ public static float ParseSingle(string key, string value)
return (float)tmp;
}

public static double ParseDouble(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 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.");
Expand Down Expand Up @@ -123,7 +129,9 @@ internal const string
SetClientLibrary = "setlib",
Protocol = "protocol",
HighIntegrity = "highIntegrity",
TcpKeepAlive = "tcpKeepAlive";
TcpKeepAlive = "tcpKeepAlive",
Weight = "weight",
Member = "member";

private static readonly Dictionary<string, string> normalizedOptions = new[]
{
Expand Down Expand Up @@ -159,6 +167,8 @@ internal const string
Protocol,
HighIntegrity,
TcpKeepAlive,
Weight,
Member,
}.ToDictionary(x => x, StringComparer.OrdinalIgnoreCase);

public static string TryNormalize(string value)
Expand Down Expand Up @@ -218,6 +228,13 @@ private enum OptionFlags : ulong

private string? tieBreaker, sslHost, configChannel, user, password;

// Per-member group metadata, parsed from a multi-group connection string ("weight="/"member="). These are
// only meaningful for a member of a multi-group (Active-Active) connection; on a single connection they are
// parsed-but-inert. See <see cref="SplitGroups"/> and <c>ConnectionMultiplexer.ConnectGroupAsync(string, ...)</c>.
internal double? MemberWeight { get; private set; }

internal string? MemberName { get; private set; }

private TimeSpan heartbeatInterval;

private CommandMap? commandMap;
Expand Down Expand Up @@ -934,6 +951,80 @@ public int ConfigCheckSeconds
public static ConfigurationOptions Parse(string configuration, bool ignoreUnknown) =>
new ConfigurationOptions().DoParse(configuration, ignoreUnknown);

/// <summary>
/// The token that delimits separate groups (members) within a multi-group (Active-Active) connection string.
/// It is only recognised as a delimiter when it appears as a whole comma-separated token (i.e. <c>,|,</c>);
/// a <c>|</c> appearing inside a value (for example <c>sslProtocols=Tls12|Tls13</c> or within a password) is
/// never treated as a delimiter, so this does not change the existing (already awkward) value-escaping rules.
/// </summary>
internal const char GroupDelimiter = '|';

/// <summary>
/// Split a (possibly) multi-group connection string into one <see cref="ConfigurationOptions"/> per group,
/// delimited by a bare <see cref="GroupDelimiter"/> token. Returns <see langword="null"/> when the string
/// contains no group delimiter (i.e. it is an ordinary single-group configuration and should be parsed via
/// <see cref="Parse(string)"/>). Empty segments (e.g. a leading <c>|</c>, which acts as an explicit
/// "this is multi-group" marker) are ignored.
/// </summary>
internal static bool IsMultiGroup(string? configuration)
{
if (string.IsNullOrEmpty(configuration)) return false;
foreach (var token in configuration!.Split(StringSplits.Comma))
{
if (IsDelimiter(token)) return true;
}
return false;

static bool IsDelimiter(string token)
{
token = token.Trim();
return token.Length == 1 && token[0] == GroupDelimiter;
}
}

internal static List<ConfigurationOptions>? SplitGroups(string configuration)
{
if (configuration is null) throw new ArgumentNullException(nameof(configuration));

var tokens = configuration.Split(StringSplits.Comma);
if (!IsMultiGroup(configuration)) return null;

var groups = new List<ConfigurationOptions>();
var current = new List<string>();
foreach (var token in tokens)
{
if (IsDelimiter(token))
{
Flush(groups, current);
}
else
{
current.Add(token);
}
}
Flush(groups, current);
return groups;

static bool IsDelimiter(string token)
{
token = token.Trim();
return token.Length == 1 && token[0] == GroupDelimiter;
}

static void Flush(List<ConfigurationOptions> groups, List<string> current)
{
if (current.Count != 0)
{
var segment = string.Join(",", current);
current.Clear();
if (!string.IsNullOrWhiteSpace(segment))
{
groups.Add(Parse(segment));
}
}
}
}

/// <summary>
/// Create a copy of the configuration.
/// </summary>
Expand Down Expand Up @@ -986,6 +1077,8 @@ public static ConfigurationOptions Parse(string configuration, bool ignoreUnknow
#endif
RequestBufferPool = RequestBufferPool,
ResponseBufferPool = ResponseBufferPool,
MemberWeight = MemberWeight,
MemberName = MemberName,
};

/// <summary>
Expand Down Expand Up @@ -1069,6 +1162,8 @@ public string ToString(bool includePassword)
Append(sb, OptionKeys.HighIntegrity, OptionFlags.HighIntegrityHasValue, OptionFlags.HighIntegrityValue);
if (HasValue(OptionFlags.ProtocolHasValue)) Append(sb, OptionKeys.Protocol, FormatProtocol(_protocol));
Append(sb, OptionKeys.TcpKeepAlive, OptionFlags.TcpKeepAliveHasValue, OptionFlags.TcpKeepAliveValue);
if (MemberWeight is double weight) Append(sb, OptionKeys.Weight, Format.ToString(weight));
Append(sb, OptionKeys.Member, MemberName);
if (Tunnel is { IsInbuilt: true } tunnel)
{
Append(sb, OptionKeys.Tunnel, tunnel.ToString());
Expand Down Expand Up @@ -1159,6 +1254,8 @@ private void Clear()
defaultOptions = null;
optionFlags = OptionFlags.None;
ClientName = ServiceName = user = password = tieBreaker = sslHost = configChannel = null;
MemberWeight = null;
MemberName = null;
keepAlive = syncTimeout = asyncTimeout = connectTimeout = responseTimeout = connectRetry = configCheckSeconds = defaultDatabase = 0;
sslProtocols = default;
defaultVersion = null;
Expand Down Expand Up @@ -1216,6 +1313,16 @@ private ConfigurationOptions DoParse(string configuration, bool ignoreUnknown)

if (string.IsNullOrWhiteSpace(option)) continue;

// a bare group-delimiter token means this is a multi-group (Active-Active) configuration; a single
// ConfigurationOptions cannot represent that, so refuse rather than silently merging every group's
// endpoints into one pool. Callers should use ConnectionMultiplexer.ConnectGroupAsync(string, ...).
if (option.Length == 1 && option[0] == GroupDelimiter)
{
throw new ArgumentException(
$"The configuration contains a group delimiter ('{GroupDelimiter}'), which denotes a multi-group (Active-Active) configuration; use ConnectionMultiplexer.ConnectGroupAsync(string, ...) to connect it.",
nameof(configuration));
}

// check for special tokens
int idx = option.IndexOf('=');
if (idx > 0)
Expand Down Expand Up @@ -1303,6 +1410,12 @@ private ConfigurationOptions DoParse(string configuration, bool ignoreUnknown)
case OptionKeys.TcpKeepAlive:
TcpKeepAlive = OptionKeys.ParseBoolean(key, value);
break;
case OptionKeys.Weight:
MemberWeight = OptionKeys.ParseDouble(key, value);
break;
case OptionKeys.Member:
MemberName = value;
break;
case OptionKeys.Tunnel:
if (value.IsNullOrWhiteSpace())
{
Expand Down
4 changes: 4 additions & 0 deletions src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
#nullable enable
[SER007]static StackExchange.Redis.ConnectionMultiplexer.ConnectGroupAsync(string! configuration, StackExchange.Redis.Availability.MultiGroupOptions? options = null, System.IO.TextWriter? log = null) -> System.Threading.Tasks.Task<StackExchange.Redis.Availability.IConnectionGroup!>!
[SER007]static StackExchange.Redis.ConnectionMultiplexer.ConnectGroup(string! configuration, StackExchange.Redis.Availability.MultiGroupOptions? options = null, System.IO.TextWriter? log = null) -> StackExchange.Redis.Availability.IConnectionGroup!
[SER007]static StackExchange.Redis.ConnectionMultiplexer.ConnectAny(string! configuration, System.IO.TextWriter? log = null) -> StackExchange.Redis.IConnectionMultiplexer!
[SER007]static StackExchange.Redis.ConnectionMultiplexer.ConnectAnyAsync(string! configuration, System.IO.TextWriter? log = null) -> System.Threading.Tasks.Task<StackExchange.Redis.IConnectionMultiplexer!>!
2 changes: 2 additions & 0 deletions tests/StackExchange.Redis.Tests/ConfigTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ orderby name
"keepAlive",
"LibraryName",
"loggerFactory",
"MemberName",
"MemberWeight",
"optionFlags",
#if DEBUG
"OutputLog",
Expand Down
Loading
Loading