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
1 change: 1 addition & 0 deletions docs/ReleaseNotes.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Current package versions:
- Add Redis 8.8 stream negative acknowledgements (`XNACK`) ([#3058 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3058))
- Update experimental `GCRA` APIs and wire protocol terminology from "requests" to "tokens", to match server change ([#3051 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3051))
- Add experimental `Aggregate.Count` support for sorted-set combination operations against Redis 8.8 ([#3059 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3059))
- Prefer RESP3 and avoid opening a separate subscription connection for Azure Managed Redis endpoints ([#3067 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3067))

## 2.12.14

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using System;
using System.Net;
using System.Threading.Tasks;
using StackExchange.Redis.Maintenance;

namespace StackExchange.Redis.Configuration
{
Expand Down Expand Up @@ -54,9 +53,15 @@ private bool IsHostInDomains(string hostName, string[] domains)

/// <inheritdoc/>
public override Task AfterConnectAsync(ConnectionMultiplexer muxer, Action<string> log)
=> AzureMaintenanceEvent.AddListenerAsync(muxer, log);
=> Task.CompletedTask;

/// <inheritdoc/>
public override bool GetDefaultSsl(EndPointCollection endPoints) => true;

/// <inheritdoc/>
public override RedisProtocol? Protocol => RedisProtocol.Resp3; // prefer RESP3 on AMR

/// <inheritdoc/>
public override string ConfigurationChannel => ""; // disable on AMR
Comment thread
philon-msft marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,11 @@ protected virtual string GetDefaultClientName() =>
/// </summary>
public virtual bool SetClientLibrary => true;

/// <summary>
/// Gets the preferred protocol to use for the connection.
/// </summary>
public virtual RedisProtocol? Protocol => null;
Comment thread
philon-msft marked this conversation as resolved.

/// <summary>
/// Tries to get the RoleInstance Id if Microsoft.WindowsAzure.ServiceRuntime is loaded.
/// In case of any failure, swallows the exception and returns null.
Expand Down
11 changes: 8 additions & 3 deletions src/StackExchange.Redis/ConfigurationOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1169,13 +1169,18 @@ private ConfigurationOptions DoParse(string configuration, bool ignoreUnknown)
/// <summary>
/// Specify the redis protocol type.
/// </summary>
public RedisProtocol? Protocol { get; set; }
public RedisProtocol? Protocol
{
get => field ?? Defaults.Protocol;
set;
}

internal bool TryResp3()
{
var protocol = Protocol;
// note: deliberately leaving the IsAvailable duplicated to use short-circuit

// if (Protocol is null)
// if (protocol is null)
// {
// // if not specified, lean on the server version and whether HELLO is available
// return new RedisFeatures(DefaultVersion).Resp3 && CommandMap.IsAvailable(RedisCommand.HELLO);
Expand All @@ -1187,7 +1192,7 @@ internal bool TryResp3()
// edge case in the library itself, the break is still visible to external callers via Execute[Async]; with an
// abundance of caution, we are therefore making RESP3 explicit opt-in only for now; we may revisit this in a major
{
return Protocol.GetValueOrDefault() >= RedisProtocol.Resp3 && CommandMap.IsAvailable(RedisCommand.HELLO);
return protocol.GetValueOrDefault() >= RedisProtocol.Resp3 && CommandMap.IsAvailable(RedisCommand.HELLO);
}
}

Expand Down
3 changes: 3 additions & 0 deletions src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
#nullable enable
override StackExchange.Redis.Configuration.AzureManagedRedisOptionsProvider.ConfigurationChannel.get -> string!
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at the risk of pointing out the obvious: don't forget to move these to PublicAPI.Shipped.txt

override StackExchange.Redis.Configuration.AzureManagedRedisOptionsProvider.Protocol.get -> StackExchange.Redis.RedisProtocol?
virtual StackExchange.Redis.Configuration.DefaultOptionsProvider.Protocol.get -> StackExchange.Redis.RedisProtocol?
57 changes: 57 additions & 0 deletions tests/StackExchange.Redis.Tests/DefaultOptionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,63 @@ public void IsMatchOnAzureManagedRedisDomain(string hostName)
Assert.IsType<AzureManagedRedisOptionsProvider>(provider);
}

[Fact]
public async Task AzureManagedRedisConnectsViaResp3WithoutSubscriptionConnection()
{
using var serverObj = new InProcessTestServer(Output, new DnsEndPoint("contoso.redis.azure.net", 10000), useSsl: true);
var config = serverObj.GetClientConfig();
config.Protocol = null;

await using var conn = await ConnectionMultiplexer.ConnectAsync(config, Writer);

var server = conn.GetServer(conn.GetEndPoints().Single());
var interactiveId = ((IInternalConnectionMultiplexer)conn).GetConnectionId(server.EndPoint, ConnectionType.Interactive);
var clients = server.ClientList();
var namedClients = clients.Where(x => x.Name == conn.ClientName).ToArray();

Assert.Equal(RedisProtocol.Resp3, server.Protocol);
Assert.Equal(1, serverObj.ClientCount);
Assert.NotNull(interactiveId);
Assert.Single(namedClients);
var self = Assert.Single(clients, x => x.Id == interactiveId);
Assert.Equal(ClientType.Normal, self.ClientType);
Assert.Equal(0, self.SubscriptionCount);
Assert.Equal(0, self.PatternSubscriptionCount);
Assert.Equal(0, self.ShardedSubscriptionCount);
}

[Fact]
public async Task VanillaResp2ConnectsWithSeparatePubSubConnection()
{
using var serverObj = new InProcessTestServer(Output, new DnsEndPoint("redis.contoso.com", 10000), useSsl: true);
var config = serverObj.GetClientConfig();
config.Protocol = RedisProtocol.Resp2;

await using var conn = await ConnectionMultiplexer.ConnectAsync(config, Writer);
var sub = conn.GetSubscriber();
await sub.SubscribeAsync(RedisChannel.Literal(nameof(VanillaResp2ConnectsWithSeparatePubSubConnection)), (_, _) => { });

var server = conn.GetServer(conn.GetEndPoints().Single());
var mux = (IInternalConnectionMultiplexer)conn;
var interactiveId = mux.GetConnectionId(server.EndPoint, ConnectionType.Interactive);
var subscriptionId = mux.GetConnectionId(server.EndPoint, ConnectionType.Subscription);
var clients = server.ClientList();
var namedClients = clients.Where(x => x.Name == conn.ClientName).ToArray();

Assert.Equal(RedisProtocol.Resp2, server.Protocol);
Assert.Equal(2, serverObj.ClientCount);
Assert.NotNull(interactiveId);
Assert.NotNull(subscriptionId);
Assert.NotEqual(interactiveId, subscriptionId);
Assert.Equal(2, namedClients.Length);

var interactive = Assert.Single(clients, x => x.Id == interactiveId);
var subscription = Assert.Single(clients, x => x.Id == subscriptionId);
Assert.Equal(ClientType.Normal, interactive.ClientType);
Assert.Equal(ClientType.PubSub, subscription.ClientType);
Assert.True(subscription.SubscriptionCount > 0);
}

[Fact]
public void AllOverridesFromDefaultsProp()
{
Expand Down
125 changes: 117 additions & 8 deletions tests/StackExchange.Redis.Tests/InProcessTestServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
using System.IO;
using System.IO.Pipelines;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
Expand All @@ -17,11 +21,22 @@ namespace StackExchange.Redis.Tests;
public class InProcessTestServer : MemoryCacheRedisServer
{
private readonly ITestOutputHelper? _log;
public InProcessTestServer(ITestOutputHelper? log = null, EndPoint? endpoint = null)
private readonly X509Certificate2? _serverCertificate;
private readonly string? _serverCertificateThumbprint;
private readonly RemoteCertificateValidationCallback? _certificateValidationCallback;

public InProcessTestServer(ITestOutputHelper? log = null, EndPoint? endpoint = null, bool useSsl = false)
: base(endpoint)
{
RedisVersion = RedisFeatures.v6_0_0; // for client to expect RESP3
_log = log;
UseSsl = useSsl;
if (useSsl)
{
_serverCertificate = CreateServerCertificate(DefaultEndPoint);
_serverCertificateThumbprint = _serverCertificate.Thumbprint;
_certificateValidationCallback = ValidateServerCertificate;
}
// ReSharper disable once VirtualMemberCallInConstructor
_log?.WriteLine($"Creating in-process server: {ToString()}");
Tunnel = new InProcTunnel(this);
Expand Down Expand Up @@ -90,6 +105,11 @@ public ConfigurationOptions GetClientConfig(bool withPubSub = true, bool default
// WriteMode = (BufferedStreamWriter.WriteMode)writeMode,
};
if (!string.IsNullOrEmpty(Password)) config.Password = Password;
if (UseSsl)
{
config.Ssl = true;
config.CertificateValidation += _certificateValidationCallback;
}

/* useful for viewing *outbound* data in the log
#if DEBUG
Expand Down Expand Up @@ -121,6 +141,7 @@ public ConfigurationOptions GetClientConfig(bool withPubSub = true, bool default
}

public Tunnel Tunnel { get; }
public bool UseSsl { get; }

public override void Log(string message)
{
Expand Down Expand Up @@ -200,6 +221,66 @@ protected override void OnSkippedReply(RedisClient client)
base.OnSkippedReply(client);
}

protected override void Dispose(bool disposing)
{
if (disposing)
{
_serverCertificate?.Dispose();
}
base.Dispose(disposing);
}

private bool ValidateServerCertificate(object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors errors)
{
if (errors == SslPolicyErrors.None)
{
return true;
}

return certificate is not null
&& _serverCertificateThumbprint is not null
&& string.Equals(certificate.GetCertHashString(), _serverCertificateThumbprint, StringComparison.OrdinalIgnoreCase);
}

private static X509Certificate2 CreateServerCertificate(EndPoint endpoint)
{
var now = DateTimeOffset.UtcNow;
var subjectName = GetCertificateSubjectName(endpoint);

using var rsa = RSA.Create(2048);
var request = new CertificateRequest($"CN={subjectName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false));
request.CertificateExtensions.Add(
new X509EnhancedKeyUsageExtension(
new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") },
false));

var san = new SubjectAlternativeNameBuilder();
switch (endpoint)
{
case DnsEndPoint dns:
san.AddDnsName(dns.Host);
break;
case IPEndPoint ip:
san.AddIpAddress(ip.Address);
break;
}
request.CertificateExtensions.Add(san.Build());

using var certificate = request.CreateSelfSigned(now.AddMinutes(-5), now.AddDays(7));
#pragma warning disable SYSLIB0057
return new X509Certificate2(certificate.Export(X509ContentType.Pfx));
#pragma warning restore SYSLIB0057

static string GetCertificateSubjectName(EndPoint endpoint) => endpoint switch
{
DnsEndPoint dns => dns.Host,
IPEndPoint ip => ip.Address.ToString(),
_ => "localhost",
};
}

private sealed class InProcTunnel(
InProcessTestServer server,
PipeOptions? pipeOptions = null) : Tunnel
Expand All @@ -225,16 +306,38 @@ private sealed class InProcTunnel(
if (server.TryGetNode(endpoint, out var node))
{
await server.OnAcceptClientAsync(endpoint);
server._log?.WriteLine(
$"[{endpoint}] accepting {connectionType} mapped to {server.ServerType} node {node} via {(server.UseSsl ? "TLS" : "plaintext")}");
var clientToServer = new Pipe(pipeOptions ?? PipeOptions.Default);
var serverToClient = new Pipe(pipeOptions ?? PipeOptions.Default);
var serverSide = new Duplex(clientToServer.Reader, serverToClient.Writer);
var serverInput = clientToServer.Reader.AsStream();
var serverOutput = serverToClient.Writer.AsStream();
var serverTransport = new DuplexStream(serverInput, serverOutput);

TaskCompletionSource<RedisClient> clientTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
Task.Run(async () => await server.RunClientAsync(serverSide, node: node, state: clientTcs), cancellationToken).RedisFireAndForget();
if (!clientTcs.Task.Wait(1000)) throw new TimeoutException("Client not connected");
var client = clientTcs.Task.Result;
server._log?.WriteLine(
$"[{client}] connected ({connectionType} mapped to {server.ServerType} node {node})");
if (server.UseSsl)
{
Task.Run(
async () =>
{
using var ssl = new SslStream(serverTransport, leaveInnerStreamOpen: false);
await ssl.AuthenticateAsServerAsync(
server._serverCertificate!,
clientCertificateRequired: false,
enabledSslProtocols: SslProtocols.None,
checkCertificateRevocation: false).ConfigureAwait(false);
var serverSide = new StreamDuplexPipe(ssl);
await server.RunClientAsync(serverSide, node: node, state: null).ConfigureAwait(false);
},
cancellationToken).RedisFireAndForget();
}
else
{
var serverSide = new Duplex(clientToServer.Reader, serverToClient.Writer);
TaskCompletionSource<RedisClient> clientTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
Task.Run(async () => await server.RunClientAsync(serverSide, node: node, state: clientTcs), cancellationToken).RedisFireAndForget();
if (!clientTcs.Task.Wait(1000)) throw new TimeoutException("Client not connected");
_ = clientTcs.Task.Result;
}

var readStream = serverToClient.Reader.AsStream();
var writeStream = clientToServer.Writer.AsStream();
Expand All @@ -256,6 +359,12 @@ public ValueTask Dispose()
return default;
}
}

private sealed class StreamDuplexPipe(Stream stream) : IDuplexPipe
{
public PipeReader Input { get; } = PipeReader.Create(stream);
public PipeWriter Output { get; } = PipeWriter.Create(stream);
}
}

protected virtual ValueTask OnAcceptClientAsync(EndPoint endpoint) => default;
Expand Down
10 changes: 10 additions & 0 deletions tests/StackExchange.Redis.Tests/SSLTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,8 @@ public void Issue883_Exhaustive()
Ssl = true,
AbortOnConnectFail = false,
};
_ = a.Defaults;
_ = b.Defaults; // ensure the lazily materialized provider matches the parsed shape
Log($"computed: {b.ToString(true)}");

Log("Checking endpoints...");
Expand All @@ -429,6 +431,14 @@ public void Issue883_Exhaustive()
Array.Sort(fields, (x, y) => string.CompareOrdinal(x.Name, y.Name));
foreach (var field in fields)
{
if (field.Name == "defaultOptions")
{
var x = field.GetValue(a);
var y = field.GetValue(b);
Log($"{field.Name}: {(x == null ? "(null)" : x.GetType().Name)} vs {(y == null ? "(null)" : y.GetType().Name)}");
Check(field.Name + ".Type", x?.GetType(), y?.GetType());
continue;
}
Check(field.Name, field.GetValue(a), field.GetValue(b));
}
}
Expand Down
26 changes: 26 additions & 0 deletions toys/StackExchange.Redis.Server/RedisServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,32 @@ protected virtual TypedRedisValue ClientReply(RedisClient client, in RedisReques
protected virtual TypedRedisValue ClientId(RedisClient client, in RedisRequest request)
=> TypedRedisValue.Integer(client.Id);

[RedisCommand(2, nameof(RedisCommand.CLIENT), "list", LockFree = true)]
protected virtual TypedRedisValue ClientList(RedisClient client, in RedisRequest request)
{
var sb = new StringBuilder();
ForAllClients(
sb,
static (other, state) =>
{
if (state.Length != 0) state.AppendLine();
state.Append("id=").Append(other.Id)
.Append(" addr=").Append(other.Node.Host).Append(':').Append(other.Node.Port)
.Append(" age=0 idle=0")
.Append(" db=").Append(other.Database)
.Append(" sub=").Append(other.SubscriptionCount)
.Append(" psub=").Append(other.PatternSubscriptionCount)
.Append(" ssub=").Append(other.ShardedSubscriptionCount)
.Append(" multi=0")
.Append(" cmd=NULL")
.Append(" name=").Append(other.Name ?? "")
.Append(" resp=").Append(other.Protocol is RedisProtocol.Resp3 ? 3 : 2)
.Append(" flags=").Append(other.IsSubscriber ? "P" : "N");
return 1;
});
return TypedRedisValue.BulkString(sb.ToString());
}

[RedisCommand(4, nameof(RedisCommand.CLIENT), "setinfo", LockFree = true)]
protected virtual TypedRedisValue ClientSetInfo(RedisClient client, in RedisRequest request)
=> TypedRedisValue.OK; // only exists to keep logs clean
Expand Down
Loading