diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md
index abe80ad12..466f2061b 100644
--- a/docs/ReleaseNotes.md
+++ b/docs/ReleaseNotes.md
@@ -9,6 +9,7 @@ Current package versions:
## Unreleased
- Fix [#2426](https://github.com/StackExchange/StackExchange.Redis/issues/2426): Don't restrict multi-slot operations on Envoy proxy; let the proxy decide ([#2428 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2428))
+- Add: Support for `User`/`Password` in `DefaultOptionsProvider` to support token rotation scenarios ([#2445 by NickCraver](https://github.com/StackExchange/StackExchange.Redis/pull/2445))
## 2.6.104
diff --git a/src/StackExchange.Redis/Configuration/DefaultOptionsProvider.cs b/src/StackExchange.Redis/Configuration/DefaultOptionsProvider.cs
index ad9a31031..f2fd42757 100644
--- a/src/StackExchange.Redis/Configuration/DefaultOptionsProvider.cs
+++ b/src/StackExchange.Redis/Configuration/DefaultOptionsProvider.cs
@@ -172,6 +172,16 @@ public static void AddProvider(DefaultOptionsProvider provider)
///
public virtual TimeSpan ConfigCheckInterval => TimeSpan.FromMinutes(1);
+ ///
+ /// The username to use to authenticate with the server.
+ ///
+ public virtual string? User => null;
+
+ ///
+ /// The password to use to authenticate with the server.
+ ///
+ public virtual string? Password => null;
+
// We memoize this to reduce cost on re-access
private string? defaultClientName;
///
diff --git a/src/StackExchange.Redis/ConfigurationOptions.cs b/src/StackExchange.Redis/ConfigurationOptions.cs
index 2d52640d8..48a20ed66 100644
--- a/src/StackExchange.Redis/ConfigurationOptions.cs
+++ b/src/StackExchange.Redis/ConfigurationOptions.cs
@@ -145,7 +145,7 @@ public static string TryNormalize(string value)
private bool? allowAdmin, abortOnConnectFail, resolveDns, ssl, checkCertificateRevocation,
includeDetailInExceptions, includePerformanceCountersInExceptions, setClientLibrary;
- private string? tieBreaker, sslHost, configChannel;
+ private string? tieBreaker, sslHost, configChannel, user, password;
private TimeSpan? heartbeatInterval;
@@ -440,14 +440,22 @@ public int KeepAlive
}
///
- /// The user to use to authenticate with the server.
+ /// The username to use to authenticate with the server.
///
- public string? User { get; set; }
+ public string? User
+ {
+ get => user ?? Defaults.User;
+ set => user = value;
+ }
///
/// The password to use to authenticate with the server.
///
- public string? Password { get; set; }
+ public string? Password
+ {
+ get => password ?? Defaults.Password;
+ set => password = value;
+ }
///
/// Specifies whether asynchronous operations should be invoked in a way that guarantees their original delivery order.
@@ -634,8 +642,8 @@ public static ConfigurationOptions Parse(string configuration, bool ignoreUnknow
allowAdmin = allowAdmin,
defaultVersion = defaultVersion,
connectTimeout = connectTimeout,
- User = User,
- Password = Password,
+ user = user,
+ password = password,
tieBreaker = tieBreaker,
ssl = ssl,
sslHost = sslHost,
@@ -726,8 +734,8 @@ public string ToString(bool includePassword)
Append(sb, OptionKeys.AllowAdmin, allowAdmin);
Append(sb, OptionKeys.Version, defaultVersion);
Append(sb, OptionKeys.ConnectTimeout, connectTimeout);
- Append(sb, OptionKeys.User, User);
- Append(sb, OptionKeys.Password, (includePassword || string.IsNullOrEmpty(Password)) ? Password : "*****");
+ Append(sb, OptionKeys.User, user);
+ Append(sb, OptionKeys.Password, (includePassword || string.IsNullOrEmpty(password)) ? password : "*****");
Append(sb, OptionKeys.TieBreaker, tieBreaker);
Append(sb, OptionKeys.Ssl, ssl);
Append(sb, OptionKeys.SslProtocols, SslProtocols?.ToString().Replace(',', '|'));
@@ -778,7 +786,7 @@ private static void Append(StringBuilder sb, string prefix, object? value)
private void Clear()
{
- ClientName = ServiceName = User = Password = tieBreaker = sslHost = configChannel = null;
+ ClientName = ServiceName = user = password = tieBreaker = sslHost = configChannel = null;
keepAlive = syncTimeout = asyncTimeout = connectTimeout = connectRetry = configCheckSeconds = DefaultDatabase = null;
allowAdmin = abortOnConnectFail = resolveDns = ssl = setClientLibrary = null;
SslProtocols = null;
@@ -873,10 +881,10 @@ private ConfigurationOptions DoParse(string configuration, bool ignoreUnknown)
DefaultVersion = OptionKeys.ParseVersion(key, value);
break;
case OptionKeys.User:
- User = value;
+ user = value;
break;
case OptionKeys.Password:
- Password = value;
+ password = value;
break;
case OptionKeys.TieBreaker:
TieBreaker = value;
diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt
index ffaf84dd6..0f5a2880d 100644
--- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt
+++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt
@@ -1788,9 +1788,11 @@ virtual StackExchange.Redis.Configuration.DefaultOptionsProvider.IncludeDetailIn
virtual StackExchange.Redis.Configuration.DefaultOptionsProvider.IncludePerformanceCountersInExceptions.get -> bool
virtual StackExchange.Redis.Configuration.DefaultOptionsProvider.IsMatch(System.Net.EndPoint! endpoint) -> bool
virtual StackExchange.Redis.Configuration.DefaultOptionsProvider.KeepAliveInterval.get -> System.TimeSpan
+virtual StackExchange.Redis.Configuration.DefaultOptionsProvider.Password.get -> string?
virtual StackExchange.Redis.Configuration.DefaultOptionsProvider.Proxy.get -> StackExchange.Redis.Proxy
virtual StackExchange.Redis.Configuration.DefaultOptionsProvider.ReconnectRetryPolicy.get -> StackExchange.Redis.IReconnectRetryPolicy?
virtual StackExchange.Redis.Configuration.DefaultOptionsProvider.ResolveDns.get -> bool
virtual StackExchange.Redis.Configuration.DefaultOptionsProvider.SetClientLibrary.get -> bool
virtual StackExchange.Redis.Configuration.DefaultOptionsProvider.SyncTimeout.get -> System.TimeSpan
virtual StackExchange.Redis.Configuration.DefaultOptionsProvider.TieBreaker.get -> string!
+virtual StackExchange.Redis.Configuration.DefaultOptionsProvider.User.get -> string?
diff --git a/tests/StackExchange.Redis.Tests/CommandTimeoutTests.cs b/tests/StackExchange.Redis.Tests/CommandTimeoutTests.cs
index 54847e2be..671345f9f 100644
--- a/tests/StackExchange.Redis.Tests/CommandTimeoutTests.cs
+++ b/tests/StackExchange.Redis.Tests/CommandTimeoutTests.cs
@@ -35,6 +35,7 @@ public async Task DefaultHeartbeatTimeout()
await pauseTask;
}
+#if DEBUG
[Fact]
public async Task DefaultHeartbeatLowTimeout()
{
@@ -60,4 +61,5 @@ public async Task DefaultHeartbeatLowTimeout()
// Await as to not bias the next test
await pauseTask;
}
+#endif
}
diff --git a/tests/StackExchange.Redis.Tests/DefaultOptionsTests.cs b/tests/StackExchange.Redis.Tests/DefaultOptionsTests.cs
index 4dd4872e7..8223a0d21 100644
--- a/tests/StackExchange.Redis.Tests/DefaultOptionsTests.cs
+++ b/tests/StackExchange.Redis.Tests/DefaultOptionsTests.cs
@@ -36,6 +36,8 @@ public class TestOptionsProvider : DefaultOptionsProvider
public override bool ResolveDns => true;
public override TimeSpan SyncTimeout => TimeSpan.FromSeconds(126);
public override string TieBreaker => "TestTiebreaker";
+ public override string? User => "TestUser";
+ public override string? Password => "TestPassword";
}
public class TestRetryPolicy : IReconnectRetryPolicy
@@ -99,6 +101,8 @@ private static void AssertAllOverrides(ConfigurationOptions options)
Assert.True(options.ResolveDns);
Assert.Equal(TimeSpan.FromSeconds(126), TimeSpan.FromMilliseconds(options.SyncTimeout));
Assert.Equal("TestTiebreaker", options.TieBreaker);
+ Assert.Equal("TestUser", options.User);
+ Assert.Equal("TestPassword", options.Password);
}
public class TestAfterConnectOptionsProvider : DefaultOptionsProvider
diff --git a/tests/StackExchange.Redis.Tests/FailoverTests.cs b/tests/StackExchange.Redis.Tests/FailoverTests.cs
index f3955c5e7..53c7e74f4 100644
--- a/tests/StackExchange.Redis.Tests/FailoverTests.cs
+++ b/tests/StackExchange.Redis.Tests/FailoverTests.cs
@@ -7,6 +7,7 @@
namespace StackExchange.Redis.Tests;
+[Collection(NonParallelCollection.Name)]
public class FailoverTests : TestBase, IAsyncLifetime
{
protected override string GetConfiguration() => GetPrimaryReplicaConfig().ToString();
@@ -196,6 +197,104 @@ public async Task DereplicateGoesToPrimary()
}
#if DEBUG
+ [Fact]
+ public async Task SubscriptionsSurviveConnectionFailureAsync()
+ {
+ using var conn = (Create(allowAdmin: true, shared: false, log: Writer, syncTimeout: 1000) as ConnectionMultiplexer)!;
+
+ var profiler = conn.AddProfiler();
+ RedisChannel channel = Me();
+ var sub = conn.GetSubscriber();
+ int counter = 0;
+ Assert.True(sub.IsConnected());
+ await sub.SubscribeAsync(channel, delegate
+ {
+ Interlocked.Increment(ref counter);
+ }).ConfigureAwait(false);
+
+ var profile1 = Log(profiler);
+
+ Assert.Equal(1, conn.GetSubscriptionsCount());
+
+ await Task.Delay(200).ConfigureAwait(false);
+
+ await sub.PublishAsync(channel, "abc").ConfigureAwait(false);
+ sub.Ping();
+ await Task.Delay(200).ConfigureAwait(false);
+
+ var counter1 = Thread.VolatileRead(ref counter);
+ Log($"Expecting 1 message, got {counter1}");
+ Assert.Equal(1, counter1);
+
+ var server = GetServer(conn);
+ var socketCount = server.GetCounters().Subscription.SocketCount;
+ Log($"Expecting 1 socket, got {socketCount}");
+ Assert.Equal(1, socketCount);
+
+ // We might fail both connections or just the primary in the time period
+ SetExpectedAmbientFailureCount(-1);
+
+ // Make sure we fail all the way
+ conn.AllowConnect = false;
+ Log("Failing connection");
+ // Fail all connections
+ server.SimulateConnectionFailure(SimulatedFailureType.All);
+ // Trigger failure (RedisTimeoutException or RedisConnectionException because
+ // of backlog behavior)
+ var ex = Assert.ThrowsAny(() => sub.Ping());
+ Assert.True(ex is RedisTimeoutException or RedisConnectionException);
+ Assert.False(sub.IsConnected(channel));
+
+ // Now reconnect...
+ conn.AllowConnect = true;
+ Log("Waiting on reconnect");
+ // Wait until we're reconnected
+ await UntilConditionAsync(TimeSpan.FromSeconds(10), () => sub.IsConnected(channel));
+ Log("Reconnected");
+ // Ensure we're reconnected
+ Assert.True(sub.IsConnected(channel));
+
+ // Ensure we've sent the subscribe command after reconnecting
+ var profile2 = Log(profiler);
+ //Assert.Equal(1, profile2.Count(p => p.Command == nameof(RedisCommand.SUBSCRIBE)));
+
+ Log("Issuing ping after reconnected");
+ sub.Ping();
+
+ var muxerSubCount = conn.GetSubscriptionsCount();
+ Log($"Muxer thinks we have {muxerSubCount} subscriber(s).");
+ Assert.Equal(1, muxerSubCount);
+
+ var muxerSubs = conn.GetSubscriptions();
+ foreach (var pair in muxerSubs)
+ {
+ var muxerSub = pair.Value;
+ Log($" Muxer Sub: {pair.Key}: (EndPoint: {muxerSub.GetCurrentServer()}, Connected: {muxerSub.IsConnected})");
+ }
+
+ Log("Publishing");
+ var published = await sub.PublishAsync(channel, "abc").ConfigureAwait(false);
+
+ Log($"Published to {published} subscriber(s).");
+ Assert.Equal(1, published);
+
+ // Give it a few seconds to get our messages
+ Log("Waiting for 2 messages");
+ await UntilConditionAsync(TimeSpan.FromSeconds(5), () => Thread.VolatileRead(ref counter) == 2);
+
+ var counter2 = Thread.VolatileRead(ref counter);
+ Log($"Expecting 2 messages, got {counter2}");
+ Assert.Equal(2, counter2);
+
+ // Log all commands at the end
+ Log("All commands since connecting:");
+ var profile3 = profiler.FinishProfiling();
+ foreach (var command in profile3)
+ {
+ Log($"{command.EndPoint}: {command}");
+ }
+ }
+
[Fact]
public async Task SubscriptionsSurvivePrimarySwitchAsync()
{
@@ -215,14 +314,8 @@ public async Task SubscriptionsSurvivePrimarySwitchAsync()
var subB = bConn.GetSubscriber();
long primaryChanged = 0, aCount = 0, bCount = 0;
- aConn.ConfigurationChangedBroadcast += delegate
- {
- Log("A noticed config broadcast: " + Interlocked.Increment(ref primaryChanged));
- };
- bConn.ConfigurationChangedBroadcast += delegate
- {
- Log("B noticed config broadcast: " + Interlocked.Increment(ref primaryChanged));
- };
+ aConn.ConfigurationChangedBroadcast += (s, args) => Log("A noticed config broadcast: " + Interlocked.Increment(ref primaryChanged) + " (Endpoint:" + args.EndPoint + ")");
+ bConn.ConfigurationChangedBroadcast += (s, args) => Log("B noticed config broadcast: " + Interlocked.Increment(ref primaryChanged) + " (Endpoint:" + args.EndPoint + ")");
subA.Subscribe(channel, (_, message) =>
{
Log("A got message: " + message);
@@ -333,8 +426,8 @@ public async Task SubscriptionsSurvivePrimarySwitchAsync()
Assert.Equal(2, Interlocked.Read(ref aCount));
Assert.Equal(2, Interlocked.Read(ref bCount));
- // Expect 12, because a sees a, but b sees a and b due to replication
- Assert.Equal(12, Interlocked.CompareExchange(ref primaryChanged, 0, 0));
+ // Expect 12, because a sees a, but b sees a and b due to replication, but contenders may add their own
+ Assert.True(Interlocked.CompareExchange(ref primaryChanged, 0, 0) >= 12);
}
catch
{
diff --git a/tests/StackExchange.Redis.Tests/PubSubTests.cs b/tests/StackExchange.Redis.Tests/PubSubTests.cs
index 3cc21fa0a..8fd23ffdd 100644
--- a/tests/StackExchange.Redis.Tests/PubSubTests.cs
+++ b/tests/StackExchange.Redis.Tests/PubSubTests.cs
@@ -782,102 +782,4 @@ public async Task AzureRedisEventsAutomaticSubscribe()
Assert.True(didUpdate);
}
}
-
- [Fact]
- public async Task SubscriptionsSurviveConnectionFailureAsync()
- {
- using var conn = (Create(allowAdmin: true, shared: false, log: Writer, syncTimeout: 1000) as ConnectionMultiplexer)!;
-
- var profiler = conn.AddProfiler();
- RedisChannel channel = Me();
- var sub = conn.GetSubscriber();
- int counter = 0;
- Assert.True(sub.IsConnected());
- await sub.SubscribeAsync(channel, delegate
- {
- Interlocked.Increment(ref counter);
- }).ConfigureAwait(false);
-
- var profile1 = Log(profiler);
-
- Assert.Equal(1, conn.GetSubscriptionsCount());
-
- await Task.Delay(200).ConfigureAwait(false);
-
- await sub.PublishAsync(channel, "abc").ConfigureAwait(false);
- sub.Ping();
- await Task.Delay(200).ConfigureAwait(false);
-
- var counter1 = Thread.VolatileRead(ref counter);
- Log($"Expecting 1 message, got {counter1}");
- Assert.Equal(1, counter1);
-
- var server = GetServer(conn);
- var socketCount = server.GetCounters().Subscription.SocketCount;
- Log($"Expecting 1 socket, got {socketCount}");
- Assert.Equal(1, socketCount);
-
- // We might fail both connections or just the primary in the time period
- SetExpectedAmbientFailureCount(-1);
-
- // Make sure we fail all the way
- conn.AllowConnect = false;
- Log("Failing connection");
- // Fail all connections
- server.SimulateConnectionFailure(SimulatedFailureType.All);
- // Trigger failure (RedisTimeoutException or RedisConnectionException because
- // of backlog behavior)
- var ex = Assert.ThrowsAny(() => sub.Ping());
- Assert.True(ex is RedisTimeoutException or RedisConnectionException);
- Assert.False(sub.IsConnected(channel));
-
- // Now reconnect...
- conn.AllowConnect = true;
- Log("Waiting on reconnect");
- // Wait until we're reconnected
- await UntilConditionAsync(TimeSpan.FromSeconds(10), () => sub.IsConnected(channel));
- Log("Reconnected");
- // Ensure we're reconnected
- Assert.True(sub.IsConnected(channel));
-
- // Ensure we've sent the subscribe command after reconnecting
- var profile2 = Log(profiler);
- //Assert.Equal(1, profile2.Count(p => p.Command == nameof(RedisCommand.SUBSCRIBE)));
-
- Log("Issuing ping after reconnected");
- sub.Ping();
-
- var muxerSubCount = conn.GetSubscriptionsCount();
- Log($"Muxer thinks we have {muxerSubCount} subscriber(s).");
- Assert.Equal(1, muxerSubCount);
-
- var muxerSubs = conn.GetSubscriptions();
- foreach (var pair in muxerSubs)
- {
- var muxerSub = pair.Value;
- Log($" Muxer Sub: {pair.Key}: (EndPoint: {muxerSub.GetCurrentServer()}, Connected: {muxerSub.IsConnected})");
- }
-
- Log("Publishing");
- var published = await sub.PublishAsync(channel, "abc").ConfigureAwait(false);
-
- Log($"Published to {published} subscriber(s).");
- Assert.Equal(1, published);
-
- // Give it a few seconds to get our messages
- Log("Waiting for 2 messages");
- await UntilConditionAsync(TimeSpan.FromSeconds(5), () => Thread.VolatileRead(ref counter) == 2);
-
- var counter2 = Thread.VolatileRead(ref counter);
- Log($"Expecting 2 messages, got {counter2}");
- Assert.Equal(2, counter2);
-
- // Log all commands at the end
- Log("All commands since connecting:");
- var profile3 = profiler.FinishProfiling();
- foreach (var command in profile3)
- {
- Log($"{command.EndPoint}: {command}");
- }
- }
}