Skip to content
Open
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
6 changes: 5 additions & 1 deletion docs/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ connection to the primary is lost, and - because the announcement is delivered o
notices when the node last named as primary is still connected but now reports itself as a replica (a failover where nothing disconnected,
such as a primary demoted in place). In that last case the client re-asks sentinel for the current primary and follows its answer, even to
a node at an address it has never seen; while sentinel still names the demoted node (sentinel itself has not yet failed over), the client
keeps asking without tearing down or reconfiguring the healthy connections.
keeps asking without tearing down or reconfiguring the healthy connections. A command answered with `READONLY` is the same signal: the node
is marked as a replica at once, rather than at the next role check (`ConfigCheckSeconds`), and the client asks sentinel on its next heartbeat.
Sentinel is queried asynchronously - waiting on a slow or unreachable sentinel holds no thread-pool thread - and the client only follows a
sentinel that holds the node it names as a healthy master (`SENTINEL MASTER` flags without `s_down`/`o_down`; sentinels without
`SENTINEL MASTER` are asked with `get-master-addr-by-name`).

An overview of mapping between the `string` and `ConfigurationOptions` representation is shown below, but you can switch between them trivially:

Expand Down
737 changes: 463 additions & 274 deletions src/StackExchange.Redis/ConnectionMultiplexer.Sentinel.cs

Large diffs are not rendered by default.

31 changes: 22 additions & 9 deletions src/StackExchange.Redis/ConnectionMultiplexer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -642,23 +642,34 @@ static async Task<ConnectionMultiplexer> ApplyAfterConnectAsync(Task<ConnectionM
}
}

private static async Task<ConnectionMultiplexer> ConnectImplAsync(ConfigurationOptions configuration, TextWriter? writer = null, ServerType? serverType = null, Availability.CircuitBreaker? groupCircuitBreaker = null, bool isGroupMember = false)
private static async Task<ConnectionMultiplexer> ConnectImplAsync(ConfigurationOptions configuration, TextWriter? writer = null, ServerType? serverType = null, Availability.CircuitBreaker? groupCircuitBreaker = null, bool isGroupMember = false, EndPointCollection? endpoints = null, bool synchronousConnect = false)
{
IDisposable? killMe = null;
ConnectionMultiplexer? killMe = null;
EventHandler<ConnectionFailedEventArgs>? connectHandler = null;
ConnectionMultiplexer? muxer = null;
var configLogger = configuration.LoggerFactory?.CreateLogger<ConnectionMultiplexer>();
var log = configLogger.With(writer);
try
{
var sw = ValueStopwatch.StartNew();
log?.LogInformationConnectingAsync(RuntimeInformation.FrameworkDescription, Utils.GetLibVersion());
if (synchronousConnect) log?.LogInformationConnectingSync(RuntimeInformation.FrameworkDescription, Utils.GetLibVersion());
else log?.LogInformationConnectingAsync(RuntimeInformation.FrameworkDescription, Utils.GetLibVersion());

muxer = CreateMultiplexer(configuration, log, serverType, out connectHandler, groupCircuitBreaker: groupCircuitBreaker, isGroupMember: isGroupMember);
muxer = CreateMultiplexer(configuration, log, serverType, out connectHandler, endpoints: endpoints, groupCircuitBreaker: groupCircuitBreaker, isGroupMember: isGroupMember);
killMe = muxer;
Interlocked.Increment(ref muxer._connectAttemptCount);
bool configured = await muxer.ReconfigureAsync(first: true, reconfigureAll: false, log, null, "connect").ObserveErrors().ForAwait();
if (!configured)
var configure = muxer.ReconfigureAsync(first: true, reconfigureAll: false, log, null, "connect").ObserveErrors();
if (synchronousConnect && !configure.IsCompleted
&& !await configure.TimeoutAfter(muxer.SyncConnectTimeout(true)).ForAwait())
{
// The public sync Sentinel boundary retains ConnectImpl's soft timeout, without blocking a
// worker inside the shared async core. Reconfigure keeps running on the returned multiplexer.
var ex = ExceptionFactory.UnableToConnect(muxer, "ConnectTimeout");
if (configuration.AbortOnConnectFail) throw ex;
muxer.LastException = ex;
muxer.Logger?.LogErrorSyncConnectTimeout(ex, ex.Message);
}
else if (!await configure.ForAwait())
{
throw ExceptionFactory.UnableToConnect(muxer, muxer.failureMessage);
}
Expand All @@ -671,7 +682,9 @@ private static async Task<ConnectionMultiplexer> ConnectImplAsync(ConfigurationO
muxer.InitializeSentinel(log);
}

await configuration.AfterConnectAsync(muxer, s => log?.LogInformationAfterConnect(s)).ForAwait();
var afterConnect = configuration.AfterConnectAsync(muxer, s => log?.LogInformationAfterConnect(s)).ObserveErrors();
if (synchronousConnect) await afterConnect.TimeoutAfter(muxer.SyncConnectTimeout(true)).ForAwait();
else await afterConnect.ForAwait();

log?.LogInformationTotalConnectTime(sw.ElapsedMilliseconds);

Expand All @@ -680,7 +693,7 @@ private static async Task<ConnectionMultiplexer> ConnectImplAsync(ConfigurationO
finally
{
if (connectHandler != null && muxer != null) muxer.ConnectionFailed -= connectHandler;
if (killMe != null) try { killMe.Dispose(); } catch { }
if (killMe != null) try { await killMe.DisposeAsync().ForAwait(); } catch { }
if (log is TextWriterLogger twLogger) twLogger.Release();
}
}
Expand Down Expand Up @@ -1765,7 +1778,7 @@ internal static void ActivateServer(ServerEndPoint server, ILogger? log)

/// <summary>
/// Claims the reconfigure gate without running a reconfigure, so that work that must not interleave with
/// one (such as <see cref="SwitchPrimary"/> rewriting the endpoint list) can make its pre-checks and its
/// one (such as <see cref="SwitchPrimaryAsync"/> rewriting the endpoint list) can make its pre-checks and its
/// side effects atomic with respect to competing reconfigures. A successful claim MUST be handed to
/// <see cref="ReconfigureAsync(bool, bool, ILogger, EndPoint, string, bool, CommandFlags, bool)"/> with
/// <c>claimed: true</c> (which releases it however it exits), or released via
Expand Down
9 changes: 9 additions & 0 deletions src/StackExchange.Redis/ResultProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,15 @@ private bool HandleCommonError(Message message, RespReader reader, PhysicalConne
case RedisErrorKind.WrongPass:
bridge?.Multiplexer.SetAuthSuspect(new RedisServerException(errorKind, message.Flags, reader.GetOverview()));
break;
case RedisErrorKind.ReadOnly:
// Sentinel-managed writes can reveal a demotion before INFO. Only mark the local role here;
// the sentinel owner's heartbeat resolves the primary (sentinel-clients: Handle reconnections).
// Standalone/cluster keep their existing error behavior, and no reconfigure starts on this IO path.
if (bridge?.Multiplexer.IsSentinelManaged == true && bridge.ServerEndPoint is { IsReplica: false } demoted)
{
demoted.MarkAsReplicaWithoutReconfigure();
}
break;
}

var server = bridge?.ServerEndPoint;
Expand Down
20 changes: 20 additions & 0 deletions src/StackExchange.Redis/ServerEndPoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,12 @@ public bool IsReplica
set => SetConfig(ref isReplica, value);
}

internal void MarkAsReplicaWithoutReconfigure()
{
Volatile.Write(ref isReplica, true);
ClearMemoized();
}

public bool ReplicaReadOnly
{
get => replicaReadOnly;
Expand Down Expand Up @@ -1174,6 +1180,20 @@ internal bool CheckInfoReplication()
return false;
}

internal async ValueTask<bool> CheckInfoReplicationAsync()
{
if (!version.IsAtLeast(RedisFeatures.v2_8_0) || !Multiplexer.CommandMap.IsAvailable(RedisCommand.INFO)
|| GetBridge(ConnectionType.Interactive, false) is not PhysicalBridge bridge) return false;

var msg = Message.Create(-1, CommandFlags.FireAndForget | CommandFlags.NoRedirect, RedisCommand.INFO, RedisLiterals.replication);
msg.SetInternalCall();
msg.SetSource(ResultProcessor.AutoConfigure, null);
if (await bridge.TryWriteAsync(msg, isReplica, bypassBacklog: true).ForAwait() != WriteResult.Success) return false;
lastInfoReplicationCheckTicks = Environment.TickCount;
ResetExponentiallyReplicationCheck();
return true;
}

private int lastInfoReplicationCheckTicks;
internal volatile int ConfigCheckSeconds;
[ThreadStatic]
Expand Down
95 changes: 86 additions & 9 deletions tests/StackExchange.Redis.Tests/InProcessSentinelServer.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Net;
Expand Down Expand Up @@ -49,20 +50,81 @@ public EndPoint Primary
/// </summary>
public static Tunnel CreateTunnel(params InProcessTestServer[] servers) => new RoutingTunnel(servers);

private int _primaryQueries;
private int _primaryQueries, _peerQueries, _replicaQueries, _legacyQueries;
private string _primaryFlags = "master";

/// <summary>How many times a client has asked this sentinel for the primary.</summary>
public int PrimaryQueries => Volatile.Read(ref _primaryQueries);

public Action? OnPrimaryQuery { get; set; }
public int PeerQueries => Volatile.Read(ref _peerQueries);
public int ReplicaQueries => Volatile.Read(ref _replicaQueries);
public int LegacyQueries => Volatile.Read(ref _legacyQueries);
public string? PrimaryError { get; set; }
public EndPoint[]? ReplicaAddressesOverride { get; set; }
public Func<Task>? BeforePrimaryReplyAsync { get; set; }
public Func<Task>? BeforePeerReplyAsync { get; set; }

protected override ValueTask ClientPauseAsync(RedisClient client, in RedisRequest request)
{
var pause = base.ClientPauseAsync(client, request);
var hook = request.Count > 1 && request.KnownCommand == RedisCommand.SENTINEL
? request.GetString(1).ToLowerInvariant() switch
{
"master" or "get-master-addr-by-name" => BeforePrimaryReplyAsync,
"sentinels" => BeforePeerReplyAsync,
_ => null,
} : null;
return hook is null ? pause : new ValueTask(AwaitPausedReplyAsync(hook, pause));

static async Task AwaitPausedReplyAsync(Func<Task> hook, ValueTask pause)
{
// Execute has already captured the response; the barrier controls delivery, not the view being read.
await hook();
await pause;
}
}

/// <summary>Replicas to report with flags, including nodes that are no longer reachable.</summary>
public ConcurrentDictionary<EndPoint, string> ReplicaFlags { get; } = new();

/// <summary>Peer routes to advertise; deliberately no MYID/runid support is needed for address discovery.</summary>
public ConcurrentDictionary<EndPoint, string> PeerFlags { get; } = new();

public string PrimaryFlags
{
get => Volatile.Read(ref _primaryFlags);
set => Volatile.Write(ref _primaryFlags, value);
}

public bool SupportsSentinelMaster { get; set; } = true;

private void CountPrimaryQuery()
{
Interlocked.Increment(ref _primaryQueries);
OnPrimaryQuery?.Invoke();
}

[RedisCommand(3, nameof(RedisCommand.SENTINEL), "master", LockFree = true)]
private TypedRedisValue SentinelMaster(RedisClient client, in RedisRequest request)
{
if (!SupportsSentinelMaster) return TypedRedisValue.Error("ERR Unknown sentinel subcommand 'master'");
var reply = PrimaryError is string error ? TypedRedisValue.Error(error)
: IsService(request) ? Describe(Primary, PrimaryFlags) : TypedRedisValue.Error("ERR No such master with that name");
CountPrimaryQuery();
return reply;
}

[RedisCommand(3, nameof(RedisCommand.SENTINEL), "get-master-addr-by-name", LockFree = true)]
private TypedRedisValue SentinelGetMasterAddrByName(RedisClient client, in RedisRequest request)
{
Interlocked.Increment(ref _primaryQueries);
Interlocked.Increment(ref _legacyQueries);
if (!IsService(request)) return TypedRedisValue.NullArray(RespPrefix.Array);
var host = GetHost(Primary, out var port);
var reply = TypedRedisValue.Rent(2, out var span, RespPrefix.Array);
span[0] = TypedRedisValue.BulkString(host);
span[1] = TypedRedisValue.BulkString(port.ToString());
CountPrimaryQuery();
return reply;
}

Expand All @@ -72,16 +134,22 @@ private TypedRedisValue SentinelGetMasterAddrByName(RedisClient client, in Redis
[RedisCommand(3, nameof(RedisCommand.SENTINEL), "slaves", LockFree = true)]
private TypedRedisValue SentinelSlaves(RedisClient client, in RedisRequest request) => Replicas(request);

// only this sentinel exists
[RedisCommand(3, nameof(RedisCommand.SENTINEL), "sentinels", LockFree = true)]
private TypedRedisValue SentinelSentinels(RedisClient client, in RedisRequest request)
=> IsService(request) ? TypedRedisValue.EmptyArray(RespPrefix.Array) : TypedRedisValue.Error("ERR No such master with that name");
{
Interlocked.Increment(ref _peerQueries);
if (!IsService(request)) return TypedRedisValue.Error("ERR No such master with that name");
var peers = PeerFlags.ToArray();
var reply = TypedRedisValue.Rent(peers.Length, out var span, RespPrefix.Array);
for (int i = 0; i < peers.Length; i++) span[i] = Describe(peers[i].Key, "sentinel," + peers[i].Value);
return reply;
}

[RedisCommand(2, nameof(RedisCommand.SENTINEL), "masters", LockFree = true)]
private TypedRedisValue SentinelMasters(RedisClient client, in RedisRequest request)
{
var reply = TypedRedisValue.Rent(1, out var span, RespPrefix.Array);
span[0] = Describe(Primary, "master");
span[0] = Describe(Primary, PrimaryFlags);
return reply;
}

Expand All @@ -91,6 +159,7 @@ private TypedRedisValue Replicas(in RedisRequest request)
{
if (!IsService(request)) return TypedRedisValue.Error("ERR No such master with that name");

Interlocked.Increment(ref _replicaQueries);
// sentinel learns the replicas from the primary, so report whatever replicates it right now
var replicas = new List<EndPoint>();
if (_monitored.TryGetNode(Primary, out var primaryNode))
Expand All @@ -104,10 +173,18 @@ private TypedRedisValue Replicas(in RedisRequest request)
}
}

if (ReplicaAddressesOverride is { } addresses)
{
replicas.Clear();
replicas.AddRange(addresses);
}
// Real sentinel keeps reporting failed replicas with down/disconnected flags until reset.
foreach (var endpoint in ReplicaFlags.Keys)
if (!replicas.Contains(endpoint)) replicas.Add(endpoint);
var reply = TypedRedisValue.Rent(replicas.Count, out var span, RespPrefix.Array);
for (int i = 0; i < replicas.Count; i++)
{
span[i] = Describe(replicas[i], "slave");
span[i] = Describe(replicas[i], ReplicaFlags.TryGetValue(replicas[i], out var flags) ? "slave," + flags : "slave");
}
return reply;
}
Expand All @@ -117,7 +194,7 @@ private TypedRedisValue Describe(EndPoint endpoint, string flags)
var host = GetHost(endpoint, out var port);
var item = TypedRedisValue.Rent(8, out var span, RespPrefix.Array);
span[0] = TypedRedisValue.BulkString("name");
span[1] = TypedRedisValue.BulkString(flags == "master" ? ServiceName : $"{host}:{port}");
span[1] = TypedRedisValue.BulkString(flags.StartsWith("master", StringComparison.Ordinal) ? ServiceName : $"{host}:{port}");
span[2] = TypedRedisValue.BulkString("ip");
span[3] = TypedRedisValue.BulkString(host);
span[4] = TypedRedisValue.BulkString("port");
Expand All @@ -139,9 +216,9 @@ private sealed class RoutingTunnel(InProcessTestServer[] servers) : Tunnel
}

public override ValueTask<EndPoint?> GetSocketConnectEndpointAsync(EndPoint endpoint, CancellationToken cancellationToken)
=> Route(endpoint) is { } tunnel ? tunnel.GetSocketConnectEndpointAsync(endpoint, cancellationToken) : base.GetSocketConnectEndpointAsync(endpoint, cancellationToken);
=> Route(endpoint) is { } tunnel ? tunnel.GetSocketConnectEndpointAsync(endpoint, cancellationToken) : throw new IOException("No in-process route for " + endpoint);

public override ValueTask<Stream?> BeforeAuthenticateAsync(EndPoint endpoint, ConnectionType connectionType, Socket? socket, CancellationToken cancellationToken)
=> Route(endpoint) is { } tunnel ? tunnel.BeforeAuthenticateAsync(endpoint, connectionType, socket, cancellationToken) : base.BeforeAuthenticateAsync(endpoint, connectionType, socket, cancellationToken);
=> Route(endpoint) is { } tunnel ? tunnel.BeforeAuthenticateAsync(endpoint, connectionType, socket, cancellationToken) : throw new IOException("No in-process route for " + endpoint);
}
}
Loading
Loading