diff --git a/docs/Configuration.md b/docs/Configuration.md index c79082fa9..532fd5854 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -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 --- diff --git a/docs/Failover.md b/docs/Failover.md index e0042e4bb..efbb7a26b 100644 --- a/docs/Failover.md +++ b/docs/Failover.md @@ -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: diff --git a/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs index 86cbb6922..4ed6fc01a 100644 --- a/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs +++ b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs @@ -55,6 +55,93 @@ public static Task ConnectGroupAsync( { return MultiGroupMultiplexer.ConnectAsync([member0, member1], options ?? MultiGroupOptions.Default, log); } + + /// + /// Creates a new instance that manages connections to multiple + /// redundant configurations parsed from a single multi-group connection string. Groups are separated by a + /// bare | token; per-member weight= and member= (name) may be specified within each group, + /// for example: east:6379,password=a,weight=10,member=US East|west:6379,password=b,weight=5,member=US West. + /// + /// The multi-group configuration string to parse. + /// Additional options for configuring this group. + /// The to log to. + [Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] +#pragma warning disable RS0026 + public static Task ConnectGroupAsync( + string configuration, + MultiGroupOptions? options = null, + TextWriter? log = null) +#pragma warning restore RS0026 + => ConnectGroupAsync(ParseGroupMembers(configuration), options, log); + + /// + /// Creates a new instance that manages connections to multiple + /// redundant configurations parsed from a single multi-group connection string; see + /// . This is a synchronous wrapper + /// (sync-over-async) over the connect, consistent with . + /// + /// The multi-group configuration string to parse. + /// Additional options for configuring this group. + /// The to log to. + [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(); + + /// + /// Connect to either a single server/configuration or a multi-group (Active-Active) configuration, choosing + /// automatically based on whether contains a group delimiter ('|'). The + /// common 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. + /// + /// The (single- or multi-group) configuration string to parse. + /// The to log to. + [Experimental(Experiments.GeoRedundantFailover, UrlFormat = Experiments.UrlFormat)] + public static async Task ConnectAnyAsync(string configuration, TextWriter? log = null) + => ConfigurationOptions.IsMultiGroup(configuration) + ? await ConnectGroupAsync(configuration, log: log).ForAwait() + : await ConnectAsync(configuration, log).ForAwait(); + + /// + /// Connect to either a single server/configuration or a multi-group (Active-Active) configuration; the + /// synchronous companion to (the multi-group path uses + /// sync-over-async, consistent with ). + /// + /// The (single- or multi-group) configuration string to parse. + /// The to log to. + [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 diff --git a/src/StackExchange.Redis/ConfigurationOptions.cs b/src/StackExchange.Redis/ConfigurationOptions.cs index 13affadd5..1bdb2efe9 100644 --- a/src/StackExchange.Redis/ConfigurationOptions.cs +++ b/src/StackExchange.Redis/ConfigurationOptions.cs @@ -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."); @@ -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 normalizedOptions = new[] { @@ -159,6 +167,8 @@ internal const string Protocol, HighIntegrity, TcpKeepAlive, + Weight, + Member, }.ToDictionary(x => x, StringComparer.OrdinalIgnoreCase); public static string TryNormalize(string value) @@ -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 and ConnectionMultiplexer.ConnectGroupAsync(string, ...). + internal double? MemberWeight { get; private set; } + + internal string? MemberName { get; private set; } + private TimeSpan heartbeatInterval; private CommandMap? commandMap; @@ -934,6 +951,80 @@ public int ConfigCheckSeconds public static ConfigurationOptions Parse(string configuration, bool ignoreUnknown) => new ConfigurationOptions().DoParse(configuration, ignoreUnknown); + /// + /// 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. ,|,); + /// a | appearing inside a value (for example sslProtocols=Tls12|Tls13 or within a password) is + /// never treated as a delimiter, so this does not change the existing (already awkward) value-escaping rules. + /// + internal const char GroupDelimiter = '|'; + + /// + /// Split a (possibly) multi-group connection string into one per group, + /// delimited by a bare token. Returns when the string + /// contains no group delimiter (i.e. it is an ordinary single-group configuration and should be parsed via + /// ). Empty segments (e.g. a leading |, which acts as an explicit + /// "this is multi-group" marker) are ignored. + /// + 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? 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(); + var current = new List(); + 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 groups, List current) + { + if (current.Count != 0) + { + var segment = string.Join(",", current); + current.Clear(); + if (!string.IsNullOrWhiteSpace(segment)) + { + groups.Add(Parse(segment)); + } + } + } + } + /// /// Create a copy of the configuration. /// @@ -986,6 +1077,8 @@ public static ConfigurationOptions Parse(string configuration, bool ignoreUnknow #endif RequestBufferPool = RequestBufferPool, ResponseBufferPool = ResponseBufferPool, + MemberWeight = MemberWeight, + MemberName = MemberName, }; /// @@ -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()); @@ -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; @@ -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) @@ -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()) { diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 7dc5c5811..40f5b62a7 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -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! +[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! diff --git a/tests/StackExchange.Redis.Tests/ConfigTests.cs b/tests/StackExchange.Redis.Tests/ConfigTests.cs index 00fe6ad1b..b6210ece4 100644 --- a/tests/StackExchange.Redis.Tests/ConfigTests.cs +++ b/tests/StackExchange.Redis.Tests/ConfigTests.cs @@ -84,6 +84,8 @@ orderby name "keepAlive", "LibraryName", "loggerFactory", + "MemberName", + "MemberWeight", "optionFlags", #if DEBUG "OutputLog", diff --git a/tests/StackExchange.Redis.Tests/MultiGroupConfigTests.cs b/tests/StackExchange.Redis.Tests/MultiGroupConfigTests.cs new file mode 100644 index 000000000..e85b87686 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/MultiGroupConfigTests.cs @@ -0,0 +1,96 @@ +using System; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Parsing of multi-group (Active-Active) connection strings. Groups are delimited by a bare | +/// comma-token (i.e. ,|,); a | glued to text or inside a value is never a delimiter, which is what +/// keeps the (awkward) value-escaping rules unchanged. Per-member weight=/member= live within a group. +/// +public class MultiGroupConfigTests +{ + [Theory] + [InlineData("localhost")] + [InlineData("localhost:6379,ssl=true,password=abc")] + [InlineData("a:6379,b:6380,password=has|pipe|inside")] // a '|' inside a value is NOT a delimiter + [InlineData("a:6379|b:6380")] // a '|' glued to text is NOT a delimiter (it's inside one comma-token) + public void SingleGroupStringsAreNotMultiGroup(string config) + => Assert.Null(ConfigurationOptions.SplitGroups(config)); + + [Fact] + public void SplitsGroupsOnBareDelimiter() + { + var groups = ConfigurationOptions.SplitGroups( + "east-1:6379,east-2:6379,password=pA,weight=10,member=US East,|,west-1:6379,password=pB,weight=5,member=US West"); + + Assert.NotNull(groups); + Assert.Equal(2, groups!.Count); + + var east = groups[0]; + Assert.Equal(2, east.EndPoints.Count); + Assert.Equal("pA", east.Password); + Assert.Equal(10d, east.MemberWeight); + Assert.Equal("US East", east.MemberName); + + var west = groups[1]; + Assert.Single(west.EndPoints); + Assert.Equal("pB", west.Password); + Assert.Equal(5d, west.MemberWeight); + Assert.Equal("US West", west.MemberName); + } + + [Fact] + public void MissingWeightAndNameAreNull() + { + var groups = ConfigurationOptions.SplitGroups("a:6379,|,b:6379"); + Assert.NotNull(groups); + Assert.Equal(2, groups!.Count); + Assert.Null(groups[0].MemberWeight); + Assert.Null(groups[0].MemberName); + } + + [Fact] + public void LeadingDelimiterIsAnIgnoredMultiGroupMarker() + { + // a leading '|' is an explicit "this is multi-group" opt-in; it does not create an empty first group + var groups = ConfigurationOptions.SplitGroups("|,a:6379,member=A,|,b:6379,member=B"); + Assert.NotNull(groups); + Assert.Equal(2, groups!.Count); + Assert.Equal("A", groups[0].MemberName); + Assert.Equal("B", groups[1].MemberName); + } + + [Fact] + public void PipeInsidePasswordSurvivesGroupSplit() + { + var groups = ConfigurationOptions.SplitGroups("a:6379,password=p|A,|,b:6379,password=pB"); + Assert.NotNull(groups); + Assert.Equal(2, groups!.Count); + Assert.Equal("p|A", groups[0].Password); // the in-value '|' is preserved; only the bare '|' delimited + Assert.Equal("pB", groups[1].Password); + } + + [Fact] + public void ParsingAMultiGroupStringAsASingleConfigThrows() + { + var ex = Assert.Throws(() => ConfigurationOptions.Parse("a:6379,|,b:6379")); + Assert.Contains("multi-group", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void WeightAndMemberRoundTripThroughToString() + { + var round = ConfigurationOptions.Parse("localhost:6379,weight=2.5,member=Primary"); + Assert.Equal(2.5d, round.MemberWeight); + Assert.Equal("Primary", round.MemberName); + + var text = round.ToString(); + Assert.Contains("weight=2.5", text); + Assert.Contains("member=Primary", text); + + var reparsed = ConfigurationOptions.Parse(text); + Assert.Equal(2.5d, reparsed.MemberWeight); + Assert.Equal("Primary", reparsed.MemberName); + } +} diff --git a/tests/StackExchange.Redis.Tests/MultiGroupTests/ConnectAnyTests.cs b/tests/StackExchange.Redis.Tests/MultiGroupTests/ConnectAnyTests.cs new file mode 100644 index 000000000..a37af0b0e --- /dev/null +++ b/tests/StackExchange.Redis.Tests/MultiGroupTests/ConnectAnyTests.cs @@ -0,0 +1,43 @@ +using System.Threading.Tasks; +using StackExchange.Redis.Availability; +using Xunit; + +namespace StackExchange.Redis.Tests.MultiGroupTests; + +/// +/// routes a single-group string to an +/// ordinary connection and a multi-group string (,|, delimited) to an Active-Active group, returning the +/// shared abstraction either way. These assert routing only (no live server is +/// required), using unreachable endpoints with abortConnect=false. +/// +public class ConnectAnyTests +{ + [Fact] + public async Task MultiGroupStringConnectsAsAGroup() + { + await using var conn = await ConnectionMultiplexer.ConnectAnyAsync( + "127.0.0.1:1,abortConnect=false,connectTimeout=200,weight=2,member=Germany" + + ",|," + + "127.0.0.1:2,abortConnect=false,connectTimeout=200,weight=9,member=Canada"); + + Assert.IsType(conn); + var group = Assert.IsAssignableFrom(conn); + + var members = group.GetMembers(); + Assert.Equal(2, members.Length); + Assert.Equal("Germany", members[0].Name); + Assert.Equal(2d, members[0].Weight); + Assert.Equal("Canada", members[1].Name); + Assert.Equal(9d, members[1].Weight); + } + + [Fact] + public async Task SingleGroupStringConnectsAsAnOrdinaryConnection() + { + await using var conn = await ConnectionMultiplexer.ConnectAnyAsync( + "127.0.0.1:1,abortConnect=false,connectTimeout=200"); + + Assert.IsNotType(conn); + Assert.False(conn is IConnectionGroup); + } +}