Skip to content

Commit 1de8dac

Browse files
mgravellNickCraver
andauthored
Implement high integrity mode for commands (#2741)
* initial implementation of #2706 * build: net8 compat * Docs: add HighIntegrity initial docs * PR fixups * Options fixes * Update tests/StackExchange.Redis.Tests/TestBase.cs * add tests that result boxes / continuations work correctly for high-integrity-mode * - switch to counter rather than entropy - add transaction work to basic tests, to ensure handled * naming is hard * - add explicit connection failure type - burn the connection on failure - add initial metrics * benchmark impact of high performance mode * be more flexible in HeartbeatConsistencyCheckPingsAsync * add config tests --------- Co-authored-by: Nick Craver <[email protected]> Co-authored-by: Nick Craver <[email protected]>
1 parent 39b992f commit 1de8dac

File tree

18 files changed

+485
-46
lines changed

18 files changed

+485
-46
lines changed

docs/Configuration.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ The `ConfigurationOptions` object has a wide range of properties, all of which a
9999
| tunnel={string} | `Tunnel` | `null` | Tunnel for connections (use `http:{proxy url}` for "connect"-based proxy server) |
100100
| setlib={bool} | `SetClientLibrary` | `true` | Whether to attempt to use `CLIENT SETINFO` to set the library name/version on the connection |
101101
| protocol={string} | `Protocol` | `null` | Redis protocol to use; see section below |
102+
| highIntegrity={bool} | `HighIntegrity` | `false` | High integrity (incurs overhead) sequence checking on every command; see section below |
102103

103104
Additional code-only options:
104105
- LoggerFactory (`ILoggerFactory`) - Default: `null`
@@ -115,6 +116,10 @@ Additional code-only options:
115116
- The thread pool to use for scheduling work to and from the socket connected to Redis, one of...
116117
- `SocketManager.Shared`: Use a shared dedicated thread pool for _all_ multiplexers (defaults to 10 threads) - best balance for most scenarios.
117118
- `SocketManager.ThreadPool`: Use the build-in .NET thread pool for scheduling. This can perform better for very small numbers of cores or with large apps on large machines that need to use more than 10 threads (total, across all multiplexers) under load. **Important**: this option isn't the default because it's subject to thread pool growth/starvation and if for example synchronous calls are waiting on a redis command to come back to unblock other threads, stalls/hangs can result. Use with caution, especially if you have sync-over-async work in play.
119+
- HighIntegrity - Default: `false`
120+
- This enables sending a sequence check command after _every single command_ sent to Redis. This is an opt-in option that incurs overhead to add this integrity check which isn't in the Redis protocol (RESP2/3) itself. The impact on this for a given workload depends on the number of commands, size of payloads, etc. as to how proportionately impactful it will be - you should test with your workloads to assess this.
121+
- This is especially relevant if your primary use case is all strings (e.g. key/value caching) where the protocol would otherwise not error.
122+
- Intended for cases where network drops (e.g. bytes from the Redis stream, not packet loss) are suspected and integrity of responses is critical.
118123
- HeartbeatConsistencyChecks - Default: `false`
119124
- Allows _always_ sending keepalive checks even if a connection isn't idle. This trades extra commands (per `HeartbeatInterval` - default 1 second) to check the network stream for consistency. If any data was lost, the result won't be as expected and the connection will be terminated ASAP. This is a check to react to any data loss at the network layer as soon as possible.
120125
- HeartbeatInterval - Default: `1000ms`

src/StackExchange.Redis/Configuration/DefaultOptionsProvider.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,17 @@ public static DefaultOptionsProvider GetProvider(EndPoint endpoint)
104104
/// </summary>
105105
public virtual bool CheckCertificateRevocation => true;
106106

107+
/// <summary>
108+
/// A Boolean value that specifies whether to use per-command validation of strict protocol validity.
109+
/// This sends an additional command after EVERY command which incurs measurable overhead.
110+
/// </summary>
111+
/// <remarks>
112+
/// The regular RESP protocol does not include correlation identifiers between requests and responses; in exceptional
113+
/// scenarios, protocol desynchronization can occur, which may not be noticed immediately; this option adds additional data
114+
/// to ensure that this cannot occur, at the cost of some (small) additional bandwidth usage.
115+
/// </remarks>
116+
public virtual bool HighIntegrity => false;
117+
107118
/// <summary>
108119
/// The number of times to repeat the initial connect cycle if no servers respond promptly.
109120
/// </summary>

src/StackExchange.Redis/ConfigurationOptions.cs

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,8 @@ internal const string
110110
CheckCertificateRevocation = "checkCertificateRevocation",
111111
Tunnel = "tunnel",
112112
SetClientLibrary = "setlib",
113-
Protocol = "protocol";
113+
Protocol = "protocol",
114+
HighIntegrity = "highIntegrity";
114115

115116
private static readonly Dictionary<string, string> normalizedOptions = new[]
116117
{
@@ -141,6 +142,7 @@ internal const string
141142
WriteBuffer,
142143
CheckCertificateRevocation,
143144
Protocol,
145+
HighIntegrity,
144146
}.ToDictionary(x => x, StringComparer.OrdinalIgnoreCase);
145147

146148
public static string TryNormalize(string value)
@@ -156,7 +158,7 @@ public static string TryNormalize(string value)
156158
private DefaultOptionsProvider? defaultOptions;
157159

158160
private bool? allowAdmin, abortOnConnectFail, resolveDns, ssl, checkCertificateRevocation, heartbeatConsistencyChecks,
159-
includeDetailInExceptions, includePerformanceCountersInExceptions, setClientLibrary;
161+
includeDetailInExceptions, includePerformanceCountersInExceptions, setClientLibrary, highIntegrity;
160162

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

@@ -279,6 +281,21 @@ public bool CheckCertificateRevocation
279281
set => checkCertificateRevocation = value;
280282
}
281283

284+
/// <summary>
285+
/// A Boolean value that specifies whether to use per-command validation of strict protocol validity.
286+
/// This sends an additional command after EVERY command which incurs measurable overhead.
287+
/// </summary>
288+
/// <remarks>
289+
/// The regular RESP protocol does not include correlation identifiers between requests and responses; in exceptional
290+
/// scenarios, protocol desynchronization can occur, which may not be noticed immediately; this option adds additional data
291+
/// to ensure that this cannot occur, at the cost of some (small) additional bandwidth usage.
292+
/// </remarks>
293+
public bool HighIntegrity
294+
{
295+
get => highIntegrity ?? Defaults.HighIntegrity;
296+
set => highIntegrity = value;
297+
}
298+
282299
/// <summary>
283300
/// Create a certificate validation check that checks against the supplied issuer even when not known by the machine.
284301
/// </summary>
@@ -769,6 +786,7 @@ public static ConfigurationOptions Parse(string configuration, bool ignoreUnknow
769786
Protocol = Protocol,
770787
heartbeatInterval = heartbeatInterval,
771788
heartbeatConsistencyChecks = heartbeatConsistencyChecks,
789+
highIntegrity = highIntegrity,
772790
};
773791

774792
/// <summary>
@@ -849,6 +867,7 @@ public string ToString(bool includePassword)
849867
Append(sb, OptionKeys.ResponseTimeout, responseTimeout);
850868
Append(sb, OptionKeys.DefaultDatabase, DefaultDatabase);
851869
Append(sb, OptionKeys.SetClientLibrary, setClientLibrary);
870+
Append(sb, OptionKeys.HighIntegrity, highIntegrity);
852871
Append(sb, OptionKeys.Protocol, FormatProtocol(Protocol));
853872
if (Tunnel is { IsInbuilt: true } tunnel)
854873
{
@@ -894,7 +913,7 @@ private void Clear()
894913
{
895914
ClientName = ServiceName = user = password = tieBreaker = sslHost = configChannel = null;
896915
keepAlive = syncTimeout = asyncTimeout = connectTimeout = connectRetry = configCheckSeconds = DefaultDatabase = null;
897-
allowAdmin = abortOnConnectFail = resolveDns = ssl = setClientLibrary = null;
916+
allowAdmin = abortOnConnectFail = resolveDns = ssl = setClientLibrary = highIntegrity = null;
898917
SslProtocols = null;
899918
defaultVersion = null;
900919
EndPoints.Clear();
@@ -1013,6 +1032,9 @@ private ConfigurationOptions DoParse(string configuration, bool ignoreUnknown)
10131032
case OptionKeys.SetClientLibrary:
10141033
SetClientLibrary = OptionKeys.ParseBoolean(key, value);
10151034
break;
1035+
case OptionKeys.HighIntegrity:
1036+
HighIntegrity = OptionKeys.ParseBoolean(key, value);
1037+
break;
10161038
case OptionKeys.Tunnel:
10171039
if (value.IsNullOrWhiteSpace())
10181040
{

src/StackExchange.Redis/Enums/ConnectionFailureType.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ public enum ConnectionFailureType
4444
/// <summary>
4545
/// It has not been possible to create an initial connection to the redis server(s).
4646
/// </summary>
47-
UnableToConnect
47+
UnableToConnect,
48+
/// <summary>
49+
/// High-integrity mode was enabled, and a failure was detected
50+
/// </summary>
51+
ResponseIntegrityFailure,
4852
}
4953
}

src/StackExchange.Redis/Message.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Microsoft.Extensions.Logging;
22
using StackExchange.Redis.Profiling;
33
using System;
4+
using System.Buffers.Binary;
45
using System.Collections.Generic;
56
using System.Diagnostics;
67
using System.Linq;
@@ -52,6 +53,8 @@ internal abstract class Message : ICompletable
5253
{
5354
public readonly int Db;
5455

56+
private uint _highIntegrityToken;
57+
5558
internal const CommandFlags InternalCallFlag = (CommandFlags)128;
5659

5760
protected RedisCommand command;
@@ -198,6 +201,13 @@ public bool IsAdmin
198201

199202
public bool IsAsking => (Flags & AskingFlag) != 0;
200203

204+
public bool IsHighIntegrity => _highIntegrityToken != 0;
205+
206+
public uint HighIntegrityToken => _highIntegrityToken;
207+
208+
internal void WithHighIntegrity(uint value)
209+
=> _highIntegrityToken = value;
210+
201211
internal bool IsScriptUnavailable => (Flags & ScriptUnavailableFlag) != 0;
202212

203213
internal void SetScriptUnavailable() => Flags |= ScriptUnavailableFlag;
@@ -710,6 +720,28 @@ internal void WriteTo(PhysicalConnection physical)
710720
}
711721
}
712722

723+
private static ReadOnlySpan<byte> ChecksumTemplate => "$4\r\nXXXX\r\n"u8;
724+
725+
internal void WriteHighIntegrityChecksumRequest(PhysicalConnection physical)
726+
{
727+
Debug.Assert(IsHighIntegrity, "should only be used for high-integrity");
728+
try
729+
{
730+
physical.WriteHeader(RedisCommand.ECHO, 1); // use WriteHeader to allow command-rewrite
731+
732+
Span<byte> chk = stackalloc byte[10];
733+
Debug.Assert(ChecksumTemplate.Length == chk.Length, "checksum template length error");
734+
ChecksumTemplate.CopyTo(chk);
735+
BinaryPrimitives.WriteUInt32LittleEndian(chk.Slice(4, 4), _highIntegrityToken);
736+
physical.WriteRaw(chk);
737+
}
738+
catch (Exception ex)
739+
{
740+
physical?.OnInternalError(ex);
741+
Fail(ConnectionFailureType.InternalFailure, ex, null, physical?.BridgeCouldBeNull?.Multiplexer);
742+
}
743+
}
744+
713745
internal static Message CreateHello(int protocolVersion, string? username, string? password, string? clientName, CommandFlags flags)
714746
=> new HelloMessage(protocolVersion, username, password, clientName, flags);
715747

src/StackExchange.Redis/PhysicalBridge.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ internal sealed class PhysicalBridge : IDisposable
7070

7171
internal string? PhysicalName => physical?.ToString();
7272

73+
private uint _nextHighIntegrityToken; // zero means not enabled
74+
7375
public DateTime? ConnectedAt { get; private set; }
7476

7577
public PhysicalBridge(ServerEndPoint serverEndPoint, ConnectionType type, int timeoutMilliseconds)
@@ -82,6 +84,11 @@ public PhysicalBridge(ServerEndPoint serverEndPoint, ConnectionType type, int ti
8284
#if !NETCOREAPP
8385
_singleWriterMutex = new MutexSlim(timeoutMilliseconds: timeoutMilliseconds);
8486
#endif
87+
if (type == ConnectionType.Interactive && Multiplexer.RawConfig.HighIntegrity)
88+
{
89+
// we just need this to be non-zero to enable tracking
90+
_nextHighIntegrityToken = 1;
91+
}
8592
}
8693

8794
private readonly int TimeoutMilliseconds;
@@ -1546,10 +1553,30 @@ private WriteResult WriteMessageToServerInsideWriteLock(PhysicalConnection conne
15461553
break;
15471554
}
15481555

1556+
if (_nextHighIntegrityToken is not 0
1557+
&& !connection.TransactionActive // validated in the UNWATCH/EXEC/DISCARD
1558+
&& message.Command is not RedisCommand.AUTH or RedisCommand.HELLO // if auth fails, ECHO may also fail; avoid confusion
1559+
)
1560+
{
1561+
// make sure this value exists early to avoid a race condition
1562+
// if the response comes back super quickly
1563+
message.WithHighIntegrity(NextHighIntegrityTokenInsideLock());
1564+
Debug.Assert(message.IsHighIntegrity, "message should be high integrity");
1565+
}
1566+
else
1567+
{
1568+
Debug.Assert(!message.IsHighIntegrity, "prior high integrity message found during transaction?");
1569+
}
15491570
connection.EnqueueInsideWriteLock(message);
15501571
isQueued = true;
15511572
message.WriteTo(connection);
15521573

1574+
if (message.IsHighIntegrity)
1575+
{
1576+
message.WriteHighIntegrityChecksumRequest(connection);
1577+
IncrementOpCount();
1578+
}
1579+
15531580
message.SetRequestSent();
15541581
IncrementOpCount();
15551582

@@ -1602,6 +1629,21 @@ private WriteResult WriteMessageToServerInsideWriteLock(PhysicalConnection conne
16021629
}
16031630
}
16041631

1632+
private uint NextHighIntegrityTokenInsideLock()
1633+
{
1634+
// inside lock: no concurrency concerns here
1635+
switch (_nextHighIntegrityToken)
1636+
{
1637+
case 0: return 0; // disabled
1638+
case uint.MaxValue:
1639+
// avoid leaving the value at zero due to wrap-around
1640+
_nextHighIntegrityToken = 1;
1641+
return ushort.MaxValue;
1642+
default:
1643+
return _nextHighIntegrityToken++;
1644+
}
1645+
}
1646+
16051647
/// <summary>
16061648
/// For testing only
16071649
/// </summary>

0 commit comments

Comments
 (0)