From 8550511d1489a1cd59b1d397b17f652adbd305bd Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 10 Mar 2026 11:18:08 +0000 Subject: [PATCH 01/24] minimal pre-v3 consolidation: - add RESPite to the solution, but *DO NOT* reference from SE.Redis - update eng tooling and migrate field parsing from FastHash to AsciiHash - update the toy server *completely* to the v3 specification --- Directory.Build.props | 2 +- Directory.Packages.props | 3 + StackExchange.Redis.sln | 14 + eng/StackExchange.Redis.Build/AsciiHash.md | 173 ++ .../AsciiHashGenerator.cs | 756 ++++++ eng/StackExchange.Redis.Build/BasicArray.cs | 85 + .../FastHashGenerator.cs | 215 -- .../FastHashGenerator.md | 64 - .../StackExchange.Redis.Build.csproj | 7 +- src/RESPite/Buffers/CycleBuffer.cs | 751 ++++++ src/RESPite/Buffers/ICycleBufferCallback.cs | 14 + src/RESPite/Buffers/MemoryTrackedPool.cs | 63 + src/RESPite/Internal/BlockBuffer.cs | 341 +++ src/RESPite/Internal/BlockBufferSerializer.cs | 96 + src/RESPite/Internal/DebugCounters.cs | 163 ++ src/RESPite/Internal/Raw.cs | 138 ++ src/RESPite/Internal/RespConstants.cs | 53 + .../Internal/RespOperationExtensions.cs | 57 + .../SynchronizedBlockBufferSerializer.cs | 122 + .../ThreadLocalBlockBufferSerializer.cs | 21 + src/RESPite/Messages/RespAttributeReader.cs | 71 + src/RESPite/Messages/RespFrameScanner.cs | 203 ++ src/RESPite/Messages/RespPrefix.cs | 100 + .../RespReader.AggregateEnumerator.cs | 279 +++ src/RESPite/Messages/RespReader.Debug.cs | 59 + .../Messages/RespReader.ScalarEnumerator.cs | 105 + src/RESPite/Messages/RespReader.Span.cs | 86 + src/RESPite/Messages/RespReader.Utils.cs | 341 +++ src/RESPite/Messages/RespReader.cs | 2037 +++++++++++++++++ src/RESPite/Messages/RespScanState.cs | 163 ++ src/RESPite/PublicAPI/PublicAPI.Shipped.txt | 1 + src/RESPite/PublicAPI/PublicAPI.Unshipped.txt | 214 ++ .../PublicAPI/net6.0/PublicAPI.Shipped.txt | 1 + .../PublicAPI/net6.0/PublicAPI.Unshipped.txt | 1 + .../PublicAPI/net8.0/PublicAPI.Shipped.txt | 1 + .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 3 + src/RESPite/RESPite.csproj | 53 + src/RESPite/RespException.cs | 11 + src/RESPite/Shared/AsciiHash.Comparers.cs | 37 + src/RESPite/Shared/AsciiHash.Instance.cs | 73 + src/RESPite/Shared/AsciiHash.Public.cs | 10 + src/RESPite/Shared/AsciiHash.cs | 294 +++ .../Shared}/Experiments.cs | 10 +- src/RESPite/Shared/FrameworkShims.Encoding.cs | 50 + src/RESPite/Shared/FrameworkShims.Stream.cs | 107 + src/RESPite/Shared/FrameworkShims.cs | 15 + src/RESPite/Shared/NullableHacks.cs | 148 ++ src/RESPite/readme.md | 6 + .../APITypes/StreamInfo.cs | 1 + src/StackExchange.Redis/Enums/RedisCommand.cs | 12 + src/StackExchange.Redis/FastHash.cs | 137 -- .../HotKeys.ResultProcessor.cs | 74 +- src/StackExchange.Redis/HotKeys.cs | 1 + src/StackExchange.Redis/HotKeysField.cs | 145 ++ .../Interfaces/IDatabase.VectorSets.cs | 1 + .../Interfaces/IDatabase.cs | 1 + .../Interfaces/IDatabaseAsync.VectorSets.cs | 1 + .../Interfaces/IDatabaseAsync.cs | 1 + src/StackExchange.Redis/KeyNotification.cs | 42 +- .../KeyNotificationType.cs | 62 +- .../KeyNotificationTypeFastHash.cs | 413 ---- .../KeyNotificationTypeMetadata.cs | 77 + .../KeyPrefixed.VectorSets.cs | 1 + src/StackExchange.Redis/PhysicalConnection.cs | 130 +- src/StackExchange.Redis/RawResult.cs | 25 + src/StackExchange.Redis/RedisChannel.cs | 2 +- src/StackExchange.Redis/RedisLiterals.cs | 25 - src/StackExchange.Redis/RedisValue.cs | 27 + .../ResultProcessor.VectorSets.cs | 63 +- src/StackExchange.Redis/ResultProcessor.cs | 41 +- .../StackExchange.Redis.csproj | 6 + .../StreamConfiguration.cs | 1 + src/StackExchange.Redis/StreamIdempotentId.cs | 1 + src/StackExchange.Redis/StreamInfoField.cs | 121 + src/StackExchange.Redis/ValueCondition.cs | 1 + .../VectorSetAddRequest.cs | 1 + src/StackExchange.Redis/VectorSetInfo.cs | 1 + src/StackExchange.Redis/VectorSetInfoField.cs | 61 + src/StackExchange.Redis/VectorSetLink.cs | 1 + .../VectorSetQuantization.cs | 15 + .../VectorSetSimilaritySearchRequest.cs | 1 + .../VectorSetSimilaritySearchResult.cs | 1 + tests/RESPite.Tests/CycleBufferTests.cs | 87 + tests/RESPite.Tests/RESPite.Tests.csproj | 22 + tests/RESPite.Tests/RespReaderTests.cs | 1081 +++++++++ tests/RESPite.Tests/RespScannerTests.cs | 18 + tests/RESPite.Tests/TestDuplexStream.cs | 229 ++ ...shBenchmarks.cs => AsciiHashBenchmarks.cs} | 84 +- .../AsciiHashSwitch.cs | 517 +++++ .../CustomConfig.cs | 2 +- .../EnumParseBenchmarks.cs | 690 ++++++ .../FormatBenchmarks.cs | 4 +- .../StackExchange.Redis.Benchmarks.csproj | 6 +- tests/StackExchange.Redis.Tests/App.config | 2 +- .../AsciiHashUnitTests.cs | 460 ++++ .../FastHashTests.cs | 153 -- .../StackExchange.Redis.Tests/GlobalUsings.cs | 3 + .../KeyNotificationTests.cs | 112 +- .../StackExchange.Redis.Tests.csproj | 1 + .../KestrelRedisServer.csproj | 2 +- .../RedisConnectionHandler.cs | 18 +- .../GlobalUsings.cs | 22 + .../RedisClient.Output.cs | 171 +- .../StackExchange.Redis.Server/RedisClient.cs | 87 +- .../RedisRequest.cs | 130 +- .../RedisServer.PubSub.cs | 49 +- .../StackExchange.Redis.Server/RedisServer.cs | 210 +- .../RespReaderExtensions.cs | 211 ++ toys/StackExchange.Redis.Server/RespServer.cs | 283 +-- .../StackExchange.Redis.Server.csproj | 6 +- .../TypedRedisValue.cs | 102 +- 111 files changed, 12128 insertions(+), 1744 deletions(-) create mode 100644 eng/StackExchange.Redis.Build/AsciiHash.md create mode 100644 eng/StackExchange.Redis.Build/AsciiHashGenerator.cs create mode 100644 eng/StackExchange.Redis.Build/BasicArray.cs delete mode 100644 eng/StackExchange.Redis.Build/FastHashGenerator.cs delete mode 100644 eng/StackExchange.Redis.Build/FastHashGenerator.md create mode 100644 src/RESPite/Buffers/CycleBuffer.cs create mode 100644 src/RESPite/Buffers/ICycleBufferCallback.cs create mode 100644 src/RESPite/Buffers/MemoryTrackedPool.cs create mode 100644 src/RESPite/Internal/BlockBuffer.cs create mode 100644 src/RESPite/Internal/BlockBufferSerializer.cs create mode 100644 src/RESPite/Internal/DebugCounters.cs create mode 100644 src/RESPite/Internal/Raw.cs create mode 100644 src/RESPite/Internal/RespConstants.cs create mode 100644 src/RESPite/Internal/RespOperationExtensions.cs create mode 100644 src/RESPite/Internal/SynchronizedBlockBufferSerializer.cs create mode 100644 src/RESPite/Internal/ThreadLocalBlockBufferSerializer.cs create mode 100644 src/RESPite/Messages/RespAttributeReader.cs create mode 100644 src/RESPite/Messages/RespFrameScanner.cs create mode 100644 src/RESPite/Messages/RespPrefix.cs create mode 100644 src/RESPite/Messages/RespReader.AggregateEnumerator.cs create mode 100644 src/RESPite/Messages/RespReader.Debug.cs create mode 100644 src/RESPite/Messages/RespReader.ScalarEnumerator.cs create mode 100644 src/RESPite/Messages/RespReader.Span.cs create mode 100644 src/RESPite/Messages/RespReader.Utils.cs create mode 100644 src/RESPite/Messages/RespReader.cs create mode 100644 src/RESPite/Messages/RespScanState.cs create mode 100644 src/RESPite/PublicAPI/PublicAPI.Shipped.txt create mode 100644 src/RESPite/PublicAPI/PublicAPI.Unshipped.txt create mode 100644 src/RESPite/PublicAPI/net6.0/PublicAPI.Shipped.txt create mode 100644 src/RESPite/PublicAPI/net6.0/PublicAPI.Unshipped.txt create mode 100644 src/RESPite/PublicAPI/net8.0/PublicAPI.Shipped.txt create mode 100644 src/RESPite/PublicAPI/net8.0/PublicAPI.Unshipped.txt create mode 100644 src/RESPite/RESPite.csproj create mode 100644 src/RESPite/RespException.cs create mode 100644 src/RESPite/Shared/AsciiHash.Comparers.cs create mode 100644 src/RESPite/Shared/AsciiHash.Instance.cs create mode 100644 src/RESPite/Shared/AsciiHash.Public.cs create mode 100644 src/RESPite/Shared/AsciiHash.cs rename src/{StackExchange.Redis => RESPite/Shared}/Experiments.cs (86%) create mode 100644 src/RESPite/Shared/FrameworkShims.Encoding.cs create mode 100644 src/RESPite/Shared/FrameworkShims.Stream.cs create mode 100644 src/RESPite/Shared/FrameworkShims.cs create mode 100644 src/RESPite/Shared/NullableHacks.cs create mode 100644 src/RESPite/readme.md delete mode 100644 src/StackExchange.Redis/FastHash.cs create mode 100644 src/StackExchange.Redis/HotKeysField.cs delete mode 100644 src/StackExchange.Redis/KeyNotificationTypeFastHash.cs create mode 100644 src/StackExchange.Redis/KeyNotificationTypeMetadata.cs create mode 100644 src/StackExchange.Redis/StreamInfoField.cs create mode 100644 src/StackExchange.Redis/VectorSetInfoField.cs create mode 100644 tests/RESPite.Tests/CycleBufferTests.cs create mode 100644 tests/RESPite.Tests/RESPite.Tests.csproj create mode 100644 tests/RESPite.Tests/RespReaderTests.cs create mode 100644 tests/RESPite.Tests/RespScannerTests.cs create mode 100644 tests/RESPite.Tests/TestDuplexStream.cs rename tests/StackExchange.Redis.Benchmarks/{FastHashBenchmarks.cs => AsciiHashBenchmarks.cs} (64%) create mode 100644 tests/StackExchange.Redis.Benchmarks/AsciiHashSwitch.cs create mode 100644 tests/StackExchange.Redis.Benchmarks/EnumParseBenchmarks.cs create mode 100644 tests/StackExchange.Redis.Tests/AsciiHashUnitTests.cs delete mode 100644 tests/StackExchange.Redis.Tests/FastHashTests.cs create mode 100644 tests/StackExchange.Redis.Tests/GlobalUsings.cs create mode 100644 toys/StackExchange.Redis.Server/GlobalUsings.cs create mode 100644 toys/StackExchange.Redis.Server/RespReaderExtensions.cs diff --git a/Directory.Build.props b/Directory.Build.props index e36f0f7d1..169fe44d1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -10,7 +10,7 @@ true $(MSBuildThisFileDirectory)Shared.ruleset NETSDK1069 - $(NoWarn);NU5105;NU1507;SER001;SER002;SER003 + $(NoWarn);NU5105;NU1507;SER001;SER002;SER003;SER004;SER005 https://stackexchange.github.io/StackExchange.Redis/ReleaseNotes https://stackexchange.github.io/StackExchange.Redis/ MIT diff --git a/Directory.Packages.props b/Directory.Packages.props index 3fa9e0e3d..9767a0ab1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,9 @@ + + + diff --git a/StackExchange.Redis.sln b/StackExchange.Redis.sln index adb1291de..c5614da7c 100644 --- a/StackExchange.Redis.sln +++ b/StackExchange.Redis.sln @@ -127,6 +127,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "eng", "eng", "{5FA0958E-6EB EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StackExchange.Redis.Build", "eng\StackExchange.Redis.Build\StackExchange.Redis.Build.csproj", "{190742E1-FA50-4E36-A8C4-88AE87654340}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RESPite", "src\RESPite\RESPite.csproj", "{05761CF5-CC46-43A6-814B-6BD2ECC1F0ED}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RESPite.Tests", "tests\RESPite.Tests\RESPite.Tests.csproj", "{CA67D8CA-6CC9-40E2-8CAC-F0B1401BEF7B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -189,6 +193,14 @@ Global {190742E1-FA50-4E36-A8C4-88AE87654340}.Debug|Any CPU.Build.0 = Debug|Any CPU {190742E1-FA50-4E36-A8C4-88AE87654340}.Release|Any CPU.ActiveCfg = Release|Any CPU {190742E1-FA50-4E36-A8C4-88AE87654340}.Release|Any CPU.Build.0 = Release|Any CPU + {05761CF5-CC46-43A6-814B-6BD2ECC1F0ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {05761CF5-CC46-43A6-814B-6BD2ECC1F0ED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {05761CF5-CC46-43A6-814B-6BD2ECC1F0ED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {05761CF5-CC46-43A6-814B-6BD2ECC1F0ED}.Release|Any CPU.Build.0 = Release|Any CPU + {CA67D8CA-6CC9-40E2-8CAC-F0B1401BEF7B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CA67D8CA-6CC9-40E2-8CAC-F0B1401BEF7B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CA67D8CA-6CC9-40E2-8CAC-F0B1401BEF7B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CA67D8CA-6CC9-40E2-8CAC-F0B1401BEF7B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -212,6 +224,8 @@ Global {69A0ACF2-DF1F-4F49-B554-F732DCA938A3} = {73A5C363-CA1F-44C4-9A9B-EF791A76BA6A} {59889284-FFEE-82E7-94CB-3B43E87DA6CF} = {73A5C363-CA1F-44C4-9A9B-EF791A76BA6A} {190742E1-FA50-4E36-A8C4-88AE87654340} = {5FA0958E-6EBD-45F4-808E-3447A293F96F} + {05761CF5-CC46-43A6-814B-6BD2ECC1F0ED} = {00CA0876-DA9F-44E8-B0DC-A88716BF347A} + {CA67D8CA-6CC9-40E2-8CAC-F0B1401BEF7B} = {73A5C363-CA1F-44C4-9A9B-EF791A76BA6A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {193AA352-6748-47C1-A5FC-C9AA6B5F000B} diff --git a/eng/StackExchange.Redis.Build/AsciiHash.md b/eng/StackExchange.Redis.Build/AsciiHash.md new file mode 100644 index 000000000..4a76ded62 --- /dev/null +++ b/eng/StackExchange.Redis.Build/AsciiHash.md @@ -0,0 +1,173 @@ +# AsciiHash + +Efficient matching of well-known short string tokens is a high-volume scenario, for example when matching RESP literals. + +The purpose of this generator is to efficiently interpret input tokens like `bin`, `f32`, etc - whether as byte or character data. + +There are multiple ways of using this tool, with the main distinction being whether you are confirming a single +token, or choosing between multiple tokens (in which case an `enum` is more appropriate): + +## Isolated literals (part 1) + +When using individual tokens, a `static partial class` can be used to generate helpers: + +``` c# +[AsciiHash] public static partial class bin { } +[AsciiHash] public static partial class f32 { } +``` + +Usually the token is inferred from the name; `[AsciiHash("real value")]` can be used if the token is not a valid identifier. +Underscores are replaced with hyphens, so a field called `my_token` has the default value `"my-token"`. +The generator demands *all* of `[AsciiHash] public static partial class`, and note that any *containing* types must +*also* be declared `partial`. + +The output is of the form: + +``` c# +static partial class bin +{ + public const int Length = 3; + public const long HashCS = ... + public const long HashUC = ... + public static ReadOnlySpan U8 => @"bin"u8; + public static string Text => @"bin"; + public static bool IsCS(in ReadOnlySpan value, long cs) => ... + public static bool IsCI(in RawResult value, long uc) => ... + +} +``` +The `CS` and `UC` are case-sensitive and case-insensitive (using upper-case) tools, respectively. + +(this API is strictly an internal implementation detail, and can change at any time) + +This generated code allows for fast, efficient, and safe matching of well-known tokens, for example: + +``` c# +var key = ... +var hash = key.HashCS(); +switch (key.Length) +{ + case bin.Length when bin.Is(key, hash): + // handle bin + break; + case f32.Length when f32.Is(key, hash): + // handle f32 + break; +} +``` + +The switch on the `Length` is optional, but recommended - these low values can often be implemented (by the compiler) +as a simple jump-table, which is very fast. However, switching on the hash itself is also valid. All hash matches +must also perform a sequence equality check - the `Is(value, hash)` convenience method validates both hash and equality. + +Note that `switch` requires `const` values, hence why we use generated *types* rather than partial-properties +that emit an instance with the known values. Also, the `"..."u8` syntax emits a span which is awkward to store, but +easy to return via a property. + +## Isolated literals (part 2) + +In some cases, you want to be able to say "match this value, only known at runtime". For this, note that `AsciiHash` +is also a `struct` that you can create an instance of and supply to code; the best way to do this is *inside* your +`partial class`: + +``` c# +[AsciiHash] +static partial class bin +{ + public static readonly AsciiHash Hash = new(U8); +} +``` + +Now, `bin.Hash` can be supplied to a caller that takes an `AsciiHash` instance (commonly with `in` semantics), +which then has *instance* methods for case-sensitive and case-insensitive matching; the instance already knows +the target hash and payload values. + +The `AsciiHash` returned implements `IEquatable` implementing case-sensitive equality; there are +also independent case-sensitive and case-insensitive comparers available via the static +`CaseSensitiveEqualityComparer` and `CaseInsensitiveEqualityComparer` properties respectively. + +Comparison values can be constructed on the fly on top of transient buffers using the constructors **that take +arrays**. Note that the other constructors may allocate on a per-usage basis. + +## Enum parsing (part 1) + +When identifying multiple values, an `enum` may be more convenient. Consider: + +``` c# +[AsciiHash] +public static partial bool TryParse(ReadOnlySpan value, out SomeEnum value); +``` + +This generates an efficient parser; inputs can be common `byte` or `char` types. Case sensitivity +is controlled by the optional `CaseSensitive` property on the attribute, or via a 3rd (`bool`) parameter +bbon the method, i.e. + +``` c# +[AsciiHash(CaseSensitive = false)] +public static partial bool TryParse(ReadOnlySpan value, out SomeEnum value); +``` + +or + +``` c# +[AsciiHash] +public static partial bool TryParse(ReadOnlySpan value, out SomeEnum value, bool caseSensitive = true); +``` + +Individual enum members can also be marked with `[AsciiHash("token value")]` to override the token payload. If +an enum member declares an empty explicit value (i.e. `[AsciiHash("")]`), then that member is ignored by the +tool; this is useful for marking "unknown" or "invalid" enum values (commonly the first enum, which by +convention has the value `0`): + +``` c# +public enum SomeEnum +{ + [AsciiHash("")] + Unknown, + SomeRealValue, + [AsciiHash("another-real-value")] + AnotherRealValue, + // ... +} +``` + +## Enum parsing (part 2) + +The tool has an *additional* facility when it comes to enums; you generally don't want to have to hard-code +things like buffer-lengths into your code, but when parsing an enum, you need to know how many bytes to read. + +The tool can generate a `static partial class` that contains the maximum length of any token in the enum, as well +as the maximum length of any token in bytes (when encoded as UTF-8). For example: + +``` c# +[AsciiHash("SomeTypeName")] +public enum SomeEnum +{ + // ... +} +``` + +This generates a class like the following: + +``` c# +static partial class SomeTypeName +{ + public const int EnumCount = 48; + public const int MaxChars = 11; + public const int MaxBytes = 11; // as UTF8 + public const int BufferBytes = 16; +} +``` + +The last of these is probably the most useful - it allows an additional byte (to rule out false-positives), +and rounds up to word-sizes, allowing for convenient stack-allocation - for example: + +``` c# +var span = reader.TryGetSpan(out var tmp) ? tmp : reader.Buffer(stackalloc byte[SomeTypeName.BufferBytes]); +if (TryParse(span, out var value)) +{ + // got a value +} +``` + +which allows for very efficient parsing of well-known tokens. \ No newline at end of file diff --git a/eng/StackExchange.Redis.Build/AsciiHashGenerator.cs b/eng/StackExchange.Redis.Build/AsciiHashGenerator.cs new file mode 100644 index 000000000..7c037856f --- /dev/null +++ b/eng/StackExchange.Redis.Build/AsciiHashGenerator.cs @@ -0,0 +1,756 @@ +using System.Buffers; +using System.Collections.Immutable; +using System.Reflection; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using RESPite; + +namespace StackExchange.Redis.Build; + +[Generator(LanguageNames.CSharp)] +public class AsciiHashGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // looking for [AsciiHash] partial static class Foo { } + var types = context.SyntaxProvider + .CreateSyntaxProvider( + static (node, _) => node is ClassDeclarationSyntax decl && IsStaticPartial(decl.Modifiers) && + HasAsciiHash(decl.AttributeLists), + TransformTypes) + .Where(pair => pair.Name is { Length: > 0 }) + .Collect(); + + // looking for [AsciiHash] partial static bool TryParse(input, out output) { } + var methods = context.SyntaxProvider + .CreateSyntaxProvider( + static (node, _) => node is MethodDeclarationSyntax decl && IsStaticPartial(decl.Modifiers) && + HasAsciiHash(decl.AttributeLists), + TransformMethods) + .Where(pair => pair.Name is { Length: > 0 }) + .Collect(); + + // looking for [AsciiHash("some type")] enum Foo { } + var enums = context.SyntaxProvider + .CreateSyntaxProvider( + static (node, _) => node is EnumDeclarationSyntax decl && HasAsciiHash(decl.AttributeLists), + TransformEnums) + .Where(pair => pair.Name is { Length: > 0 }) + .Collect(); + + context.RegisterSourceOutput( + types.Combine(methods).Combine(enums), + (ctx, content) => + Generate(ctx, content.Left.Left, content.Left.Right, content.Right)); + + static bool IsStaticPartial(SyntaxTokenList tokens) + => tokens.Any(SyntaxKind.StaticKeyword) && tokens.Any(SyntaxKind.PartialKeyword); + + static bool HasAsciiHash(SyntaxList attributeLists) + { + foreach (var attribList in attributeLists) + { + foreach (var attrib in attribList.Attributes) + { + if (attrib.Name.ToString() is nameof(AsciiHashAttribute) or nameof(AsciiHash)) return true; + } + } + + return false; + } + } + + private static string GetName(INamedTypeSymbol type) + { + if (type.ContainingType is null) return type.Name; + var stack = new Stack(); + while (true) + { + stack.Push(type.Name); + if (type.ContainingType is null) break; + type = type.ContainingType; + } + + var sb = new StringBuilder(stack.Pop()); + while (stack.Count != 0) + { + sb.Append('.').Append(stack.Pop()); + } + + return sb.ToString(); + } + + private static AttributeData? TryGetAsciiHashAttribute(ImmutableArray attributes) + { + foreach (var attrib in attributes) + { + if (attrib.AttributeClass is + { + Name: nameof(AsciiHashAttribute), + ContainingType: null, + ContainingNamespace: + { + Name: "RESPite", + ContainingNamespace.IsGlobalNamespace: true, + } + }) + { + return attrib; + } + } + + return null; + } + + private (string Namespace, string ParentType, string Name, int Count, int MaxChars, int MaxBytes) TransformEnums( + GeneratorSyntaxContext ctx, CancellationToken cancellationToken) + { + // extract the name and value (defaults to name, but can be overridden via attribute) and the location + if (ctx.SemanticModel.GetDeclaredSymbol(ctx.Node) is not INamedTypeSymbol { TypeKind: TypeKind.Enum } named) return default; + if (TryGetAsciiHashAttribute(named.GetAttributes()) is not { } attrib) return default; + var innerName = GetRawValue("", attrib); + if (string.IsNullOrWhiteSpace(innerName)) return default; + + string ns = "", parentType = ""; + if (named.ContainingType is { } containingType) + { + parentType = GetName(containingType); + ns = containingType.ContainingNamespace.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + } + else if (named.ContainingNamespace is { } containingNamespace) + { + ns = containingNamespace.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + } + + int maxChars = 0, maxBytes = 0, count = 0; + foreach (var member in named.GetMembers()) + { + if (member.Kind is SymbolKind.Field) + { + var rawValue = GetRawValue(member.Name, TryGetAsciiHashAttribute(member.GetAttributes())); + if (string.IsNullOrWhiteSpace(rawValue)) continue; + + count++; + maxChars = Math.Max(maxChars, rawValue.Length); + maxBytes = Math.Max(maxBytes, Encoding.UTF8.GetByteCount(rawValue)); + } + } + return (ns, parentType, innerName, count, maxChars, maxBytes); + } + + private (string Namespace, string ParentType, string Name, string Value) TransformTypes( + GeneratorSyntaxContext ctx, + CancellationToken cancellationToken) + { + // extract the name and value (defaults to name, but can be overridden via attribute) and the location + if (ctx.SemanticModel.GetDeclaredSymbol(ctx.Node) is not INamedTypeSymbol { TypeKind: TypeKind.Class } named) return default; + if (TryGetAsciiHashAttribute(named.GetAttributes()) is not { } attrib) return default; + + string ns = "", parentType = ""; + if (named.ContainingType is { } containingType) + { + parentType = GetName(containingType); + ns = containingType.ContainingNamespace.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + } + else if (named.ContainingNamespace is { } containingNamespace) + { + ns = containingNamespace.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + } + + string name = named.Name, value = GetRawValue(name, attrib); + if (string.IsNullOrWhiteSpace(value)) return default; + return (ns, parentType, name, value); + } + + private static string GetRawValue(string name, AttributeData? asciiHashAttribute) + { + var value = ""; + if (asciiHashAttribute is { ConstructorArguments.Length: 1 } + && asciiHashAttribute.ConstructorArguments[0].Value?.ToString() is { Length: > 0 } val) + { + value = val; + } + if (string.IsNullOrWhiteSpace(value)) + { + value = InferPayload(name); // if nothing explicit: infer from name + } + + return value; + } + + private static string InferPayload(string name) => name.Replace("_", "-"); + + private (string Namespace, string ParentType, Accessibility Accessibility, string Name, + (string Type, string Name, bool IsBytes, RefKind RefKind) From, (string Type, string Name, RefKind RefKind) To, + (string Name, bool Value, RefKind RefKind) CaseSensitive, + BasicArray<(string EnumMember, string ParseText)> Members, int DefaultValue) TransformMethods( + GeneratorSyntaxContext ctx, + CancellationToken cancellationToken) + { + if (ctx.SemanticModel.GetDeclaredSymbol(ctx.Node) is not IMethodSymbol + { + IsStatic: true, + IsPartialDefinition: true, + PartialImplementationPart: null, + Arity: 0, + ReturnType.SpecialType: SpecialType.System_Boolean, + Parameters: + { + IsDefaultOrEmpty: false, + Length: 2 or 3, + }, + } method) return default; + + if (TryGetAsciiHashAttribute(method.GetAttributes()) is not { } attrib) return default; + + if (method.ContainingType is not { } containingType) return default; + var parentType = GetName(containingType); + var ns = containingType.ContainingNamespace.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + + var arg = method.Parameters[0]; + if (arg is not { IsOptional: false, RefKind: RefKind.None or RefKind.In or RefKind.Ref or RefKind.RefReadOnlyParameter }) return default; + var fromType = arg.Type.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + bool fromBytes = fromType is "byte[]" || fromType.EndsWith("Span"); + var from = (fromType, arg.Name, fromBytes, arg.RefKind); + + arg = method.Parameters[1]; + if (arg is not + { + IsOptional: false, RefKind: RefKind.Out or RefKind.Ref, Type: INamedTypeSymbol { TypeKind: TypeKind.Enum } + }) return default; + var to = (arg.Type.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat), arg.Name, arg.RefKind); + + var members = arg.Type.GetMembers(); + var builder = new BasicArray<(string EnumMember, string ParseText)>.Builder(members.Length); + HashSet values = new(); + foreach (var member in members) + { + if (member is IFieldSymbol { IsStatic: true, IsConst: true } field) + { + var rawValue = GetRawValue(field.Name, TryGetAsciiHashAttribute(member.GetAttributes())); + if (string.IsNullOrWhiteSpace(rawValue)) continue; + builder.Add((field.Name, rawValue)); + int value = field.ConstantValue switch + { + sbyte i8 => i8, + short i16 => i16, + int i32 => i32, + long i64 => (int)i64, + byte u8 => u8, + ushort u16 => u16, + uint u32 => (int)u32, + ulong u64 => (int)u64, + char c16 => c16, + _ => 0, + }; + values.Add(value); + } + } + + (string, bool, RefKind) caseSensitive; + bool cs = IsCaseSensitive(attrib); + if (method.Parameters.Length > 2) + { + arg = method.Parameters[2]; + if (arg is not + { + RefKind: RefKind.None or RefKind.In or RefKind.Ref or RefKind.RefReadOnlyParameter, + Type.SpecialType: SpecialType.System_Boolean, + }) + { + return default; + } + + if (arg.IsOptional) + { + if (arg.ExplicitDefaultValue is not bool dv) return default; + cs = dv; + } + caseSensitive = (arg.Name, cs, arg.RefKind); + } + else + { + caseSensitive = ("", cs, RefKind.None); + } + + int defaultValue = 0; + if (values.Contains(0)) + { + int len = values.Count; + for (int i = 1; i <= len; i++) + { + if (!values.Contains(i)) + { + defaultValue = i; + break; + } + } + } + return (ns, parentType, method.DeclaredAccessibility, method.Name, from, to, caseSensitive, builder.Build(), defaultValue); + } + + private bool IsCaseSensitive(AttributeData attrib) + { + foreach (var member in attrib.NamedArguments) + { + if (member.Key == nameof(AsciiHashAttribute.CaseSensitive) + && member.Value.Kind is TypedConstantKind.Primitive + && member.Value.Value is bool caseSensitive) + { + return caseSensitive; + } + } + + return true; + } + + private string GetVersion() + { + var asm = GetType().Assembly; + if (asm.GetCustomAttributes(typeof(AssemblyFileVersionAttribute), false).FirstOrDefault() is + AssemblyFileVersionAttribute { Version: { Length: > 0 } } version) + { + return version.Version; + } + + return asm.GetName().Version?.ToString() ?? "??"; + } + + private void Generate( + SourceProductionContext ctx, + ImmutableArray<(string Namespace, string ParentType, string Name, string Value)> types, + ImmutableArray<(string Namespace, string ParentType, Accessibility Accessibility, string Name, + (string Type, string Name, bool IsBytes, RefKind RefKind) From, (string Type, string Name, RefKind RefKind) To, + (string Name, bool Value, RefKind RefKind) CaseSensitive, + BasicArray<(string EnumMember, string ParseText)> Members, int DefaultValue)> parseMethods, + ImmutableArray<(string Namespace, string ParentType, string Name, int Count, int MaxChars, int MaxBytes)> enums) + { + if (types.IsDefaultOrEmpty & parseMethods.IsDefaultOrEmpty & enums.IsDefaultOrEmpty) return; // nothing to do + + var sb = new StringBuilder("// ") + .AppendLine().Append("// ").Append(GetType().Name).Append(" v").Append(GetVersion()).AppendLine(); + + sb.AppendLine("using System;"); + sb.AppendLine("using StackExchange.Redis;"); + sb.AppendLine("#pragma warning disable CS8981, SER004"); + + BuildTypeImplementations(sb, types); + BuildEnumParsers(sb, parseMethods); + BuildEnumLengths(sb, enums); + ctx.AddSource(nameof(AsciiHash) + ".generated.cs", sb.ToString()); + } + + private void BuildEnumLengths(StringBuilder sb, ImmutableArray<(string Namespace, string ParentType, string Name, int Count, int MaxChars, int MaxBytes)> enums) + { + if (enums.IsDefaultOrEmpty) return; // nope + + int indent = 0; + StringBuilder NewLine() => sb.AppendLine().Append(' ', indent * 4); + + foreach (var grp in enums.GroupBy(l => (l.Namespace, l.ParentType))) + { + NewLine(); + int braces = 0; + if (!string.IsNullOrWhiteSpace(grp.Key.Namespace)) + { + NewLine().Append("namespace ").Append(grp.Key.Namespace); + NewLine().Append("{"); + indent++; + braces++; + } + + if (!string.IsNullOrWhiteSpace(grp.Key.ParentType)) + { + if (grp.Key.ParentType.Contains('.')) // nested types + { + foreach (var part in grp.Key.ParentType.Split('.')) + { + NewLine().Append("partial class ").Append(part); + NewLine().Append("{"); + indent++; + braces++; + } + } + else + { + NewLine().Append("partial class ").Append(grp.Key.ParentType); + NewLine().Append("{"); + indent++; + braces++; + } + } + + foreach (var @enum in grp) + { + NewLine().Append("internal static partial class ").Append(@enum.Name); + NewLine().Append("{"); + indent++; + NewLine().Append("public const int EnumCount = ").Append(@enum.Count).Append(";"); + NewLine().Append("public const int MaxChars = ").Append(@enum.MaxChars).Append(";"); + NewLine().Append("public const int MaxBytes = ").Append(@enum.MaxBytes).Append("; // as UTF8"); + // for buffer bytes: we want to allow 1 extra byte (to check for false-positive over-long values), + // and then round up to the nearest multiple of 8 (for stackalloc performance, etc) + int bufferBytes = (@enum.MaxBytes + 1 + 7) & ~7; + NewLine().Append("public const int BufferBytes = ").Append(bufferBytes).Append(";"); + indent--; + NewLine().Append("}"); + } + + // handle any closing braces + while (braces-- > 0) + { + indent--; + NewLine().Append("}"); + } + } + } + + private void BuildEnumParsers( + StringBuilder sb, + in ImmutableArray<(string Namespace, string ParentType, Accessibility Accessibility, string Name, + (string Type, string Name, bool IsBytes, RefKind RefKind) From, + (string Type, string Name, RefKind RefKind) To, + (string Name, bool Value, RefKind RefKind) CaseSensitive, + BasicArray<(string EnumMember, string ParseText)> Members, int DefaultValue)> enums) + { + if (enums.IsDefaultOrEmpty) return; // nope + + int indent = 0; + StringBuilder NewLine() => sb.AppendLine().Append(' ', indent * 4); + + foreach (var grp in enums.GroupBy(l => (l.Namespace, l.ParentType))) + { + NewLine(); + int braces = 0; + if (!string.IsNullOrWhiteSpace(grp.Key.Namespace)) + { + NewLine().Append("namespace ").Append(grp.Key.Namespace); + NewLine().Append("{"); + indent++; + braces++; + } + + if (!string.IsNullOrWhiteSpace(grp.Key.ParentType)) + { + if (grp.Key.ParentType.Contains('.')) // nested types + { + foreach (var part in grp.Key.ParentType.Split('.')) + { + NewLine().Append("partial class ").Append(part); + NewLine().Append("{"); + indent++; + braces++; + } + } + else + { + NewLine().Append("partial class ").Append(grp.Key.ParentType); + NewLine().Append("{"); + indent++; + braces++; + } + } + + foreach (var method in grp) + { + var line = NewLine().Append(Format(method.Accessibility)).Append(" static partial bool ") + .Append(method.Name).Append("(") + .Append(Format(method.From.RefKind)) + .Append(method.From.Type).Append(" ").Append(method.From.Name).Append(", ") + .Append(Format(method.To.RefKind)) + .Append(method.To.Type).Append(" ").Append(method.To.Name); + if (!string.IsNullOrEmpty(method.CaseSensitive.Name)) + { + line.Append(", ").Append(Format(method.CaseSensitive.RefKind)).Append("bool ") + .Append(method.CaseSensitive.Name); + } + line.Append(")"); + NewLine().Append("{"); + indent++; + NewLine().Append("// ").Append(method.To.Type).Append(" has ").Append(method.Members.Length).Append(" members"); + string valueTarget = method.To.Name; + if (method.To.RefKind != RefKind.Out) + { + valueTarget = "__tmp"; + NewLine().Append(method.To.Type).Append(" ").Append(valueTarget).Append(";"); + } + + bool alwaysCaseSensitive = + string.IsNullOrEmpty(method.CaseSensitive.Name) && method.CaseSensitive.Value; + if (!alwaysCaseSensitive && !HasCaseSensitiveCharacters(method.Members)) + { + alwaysCaseSensitive = true; + } + + bool twoPart = method.Members.Max(x => x.ParseText.Length) > AsciiHash.MaxBytesHashed; + if (alwaysCaseSensitive) + { + if (twoPart) + { + NewLine().Append("global::RESPite.AsciiHash.HashCS(").Append(method.From.Name).Append(", out var cs0, out var cs1);"); + } + else + { + NewLine().Append("var cs0 = global::RESPite.AsciiHash.HashCS(").Append(method.From.Name).Append(");"); + } + } + else + { + if (twoPart) + { + NewLine().Append("global::RESPite.AsciiHash.Hash(").Append(method.From.Name) + .Append(", out var cs0, out var uc0, out var cs1, out var uc1);"); + } + else + { + NewLine().Append("global::RESPite.AsciiHash.Hash(").Append(method.From.Name) + .Append(", out var cs0, out var uc0);"); + } + } + + if (string.IsNullOrEmpty(method.CaseSensitive.Name)) + { + Write(method.CaseSensitive.Value); + } + else + { + NewLine().Append("if (").Append(method.CaseSensitive.Name).Append(")"); + NewLine().Append("{"); + indent++; + Write(true); + indent--; + NewLine().Append("}"); + NewLine().Append("else"); + NewLine().Append("{"); + indent++; + Write(false); + indent--; + NewLine().Append("}"); + } + + if (method.To.RefKind == RefKind.Out) + { + NewLine().Append("if (").Append(valueTarget).Append(" == (") + .Append(method.To.Type).Append(")").Append(method.DefaultValue).Append(")"); + NewLine().Append("{"); + indent++; + NewLine().Append("// by convention, init to zero on miss"); + NewLine().Append(valueTarget).Append(" = default;"); + NewLine().Append("return false;"); + indent--; + NewLine().Append("}"); + NewLine().Append("return true;"); + } + else + { + NewLine().Append("// do not update parameter on miss"); + NewLine().Append("if (").Append(valueTarget).Append(" == (") + .Append(method.To.Type).Append(")").Append(method.DefaultValue).Append(") return false;"); + NewLine().Append(method.To.Name).Append(" = ").Append(valueTarget).Append(";"); + NewLine().Append("return true;"); + } + + void Write(bool caseSensitive) + { + NewLine().Append(valueTarget).Append(" = ").Append(method.From.Name).Append(".Length switch {"); + indent++; + foreach (var member in method.Members + .OrderBy(x => x.ParseText.Length) + .ThenBy(x => x.ParseText)) + { + var len = member.ParseText.Length; + AsciiHash.Hash(member.ParseText, out var cs0, out var uc0, out var cs1, out var uc1); + + bool valueCaseSensitive = caseSensitive || !HasCaseSensitiveCharacters(member.ParseText); + + line = NewLine().Append(len).Append(" when "); + if (twoPart) line.Append("("); + if (valueCaseSensitive) + { + line.Append("cs0 is ").Append(cs0); + } + else + { + line.Append("uc0 is ").Append(uc0); + } + + if (len > AsciiHash.MaxBytesHashed) + { + if (valueCaseSensitive) + { + line.Append(" & cs1 is ").Append(cs1); + } + else + { + line.Append(" & uc1 is ").Append(uc1); + } + } + if (twoPart) line.Append(")"); + if (len > 2 * AsciiHash.MaxBytesHashed) + { + line.Append(" && "); + var csValue = SyntaxFactory + .LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(member.ParseText.Substring(2 * AsciiHash.MaxBytesHashed))) + .ToFullString(); + + line.Append("global::RESPite.AsciiHash.") + .Append(valueCaseSensitive ? nameof(AsciiHash.SequenceEqualsCS) : nameof(AsciiHash.SequenceEqualsCI)) + .Append("(").Append(method.From.Name).Append(".Slice(").Append(2 * AsciiHash.MaxBytesHashed).Append("), ").Append(csValue); + if (method.From.IsBytes) line.Append("u8"); + line.Append(")"); + } + + line.Append(" => ").Append(method.To.Type).Append(".").Append(member.EnumMember).Append(","); + } + + NewLine().Append("_ => (").Append(method.To.Type).Append(")").Append(method.DefaultValue) + .Append(","); + indent--; + NewLine().Append("};"); + } + + indent--; + NewLine().Append("}"); + } + + // handle any closing braces + while (braces-- > 0) + { + indent--; + NewLine().Append("}"); + } + } + } + + private static bool HasCaseSensitiveCharacters(string value) + { + foreach (char c in value ?? "") + { + if (char.IsLetter(c)) return true; + } + + return false; + } + + private static bool HasCaseSensitiveCharacters(BasicArray<(string EnumMember, string ParseText)> members) + { + // do we have alphabet characters? case sensitivity doesn't apply if not + foreach (var member in members) + { + if (HasCaseSensitiveCharacters(member.ParseText)) return true; + } + + return false; + } + + private static string Format(RefKind refKind) => refKind switch + { + RefKind.None => "", + RefKind.In => "in ", + RefKind.Out => "out ", + RefKind.Ref => "ref ", + RefKind.RefReadOnlyParameter or RefKind.RefReadOnly => "ref readonly ", + _ => throw new NotSupportedException($"RefKind {refKind} is not yet supported."), + }; + private static string Format(Accessibility accessibility) => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Private => "private", + Accessibility.Internal => "internal", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => throw new NotSupportedException($"Accessibility {accessibility} is not yet supported."), + }; + + private static void BuildTypeImplementations( + StringBuilder sb, + in ImmutableArray<(string Namespace, string ParentType, string Name, string Value)> types) + { + if (types.IsDefaultOrEmpty) return; // nope + + int indent = 0; + StringBuilder NewLine() => sb.AppendLine().Append(' ', indent * 4); + + foreach (var grp in types.GroupBy(l => (l.Namespace, l.ParentType))) + { + NewLine(); + int braces = 0; + if (!string.IsNullOrWhiteSpace(grp.Key.Namespace)) + { + NewLine().Append("namespace ").Append(grp.Key.Namespace); + NewLine().Append("{"); + indent++; + braces++; + } + + if (!string.IsNullOrWhiteSpace(grp.Key.ParentType)) + { + if (grp.Key.ParentType.Contains('.')) // nested types + { + foreach (var part in grp.Key.ParentType.Split('.')) + { + NewLine().Append("partial class ").Append(part); + NewLine().Append("{"); + indent++; + braces++; + } + } + else + { + NewLine().Append("partial class ").Append(grp.Key.ParentType); + NewLine().Append("{"); + indent++; + braces++; + } + } + + foreach (var literal in grp) + { + // perform string escaping on the generated value (this includes the quotes, note) + var csValue = SyntaxFactory + .LiteralExpression(SyntaxKind.StringLiteralExpression, SyntaxFactory.Literal(literal.Value)) + .ToFullString(); + + AsciiHash.Hash(literal.Value, out var hashCS, out var hashUC); + NewLine().Append("static partial class ").Append(literal.Name); + NewLine().Append("{"); + indent++; + NewLine().Append("public const int Length = ").Append(literal.Value.Length).Append(';'); + NewLine().Append("public const long HashCS = ").Append(hashCS).Append(';'); + NewLine().Append("public const long HashUC = ").Append(hashUC).Append(';'); + NewLine().Append("public static ReadOnlySpan U8 => ").Append(csValue).Append("u8;"); + NewLine().Append("public const string Text = ").Append(csValue).Append(';'); + if (literal.Value.Length <= AsciiHash.MaxBytesHashed) + { + // the case-sensitive hash enforces all the values + NewLine().Append( + "public static bool IsCS(ReadOnlySpan value, long cs) => cs == HashCS & value.Length == Length;"); + NewLine().Append( + "public static bool IsCI(ReadOnlySpan value, long uc) => uc == HashUC & value.Length == Length;"); + } + else + { + NewLine().Append( + "public static bool IsCS(ReadOnlySpan value, long cs) => cs == HashCS && value.SequenceEqual(U8);"); + NewLine().Append( + "public static bool IsCI(ReadOnlySpan value, long uc) => uc == HashUC && global::RESPite.AsciiHash.SequenceEqualsCI(value, U8);"); + } + + indent--; + NewLine().Append("}"); + } + + // handle any closing braces + while (braces-- > 0) + { + indent--; + NewLine().Append("}"); + } + } + } +} diff --git a/eng/StackExchange.Redis.Build/BasicArray.cs b/eng/StackExchange.Redis.Build/BasicArray.cs new file mode 100644 index 000000000..dc7984c75 --- /dev/null +++ b/eng/StackExchange.Redis.Build/BasicArray.cs @@ -0,0 +1,85 @@ +using System.Collections; + +namespace StackExchange.Redis.Build; + +// like ImmutableArray, but with decent equality semantics +public readonly struct BasicArray : IEquatable>, IReadOnlyList +{ + private readonly T[] _elements; + + private BasicArray(T[] elements, int length) + { + _elements = elements; + Length = length; + } + + private static readonly EqualityComparer _comparer = EqualityComparer.Default; + + public int Length { get; } + public bool IsEmpty => Length == 0; + + public ref readonly T this[int index] + { + get + { + if (index < 0 | index >= Length) Throw(); + return ref _elements[index]; + + static void Throw() => throw new IndexOutOfRangeException(); + } + } + + public ReadOnlySpan Span => _elements.AsSpan(0, Length); + + public bool Equals(BasicArray other) + { + if (Length != other.Length) return false; + var y = other.Span; + int i = 0; + foreach (ref readonly T el in this.Span) + { + if (!_comparer.Equals(el, y[i])) return false; + } + + return true; + } + + public ReadOnlySpan.Enumerator GetEnumerator() => Span.GetEnumerator(); + + private IEnumerator EnumeratorCore() + { + for (int i = 0; i < Length; i++) yield return this[i]; + } + + public override bool Equals(object? obj) => obj is BasicArray other && Equals(other); + + public override int GetHashCode() + { + var hash = Length; + foreach (ref readonly T el in this.Span) + { + _ = (hash * -37) + _comparer.GetHashCode(el); + } + + return hash; + } + IEnumerator IEnumerable.GetEnumerator() => EnumeratorCore(); + IEnumerator IEnumerable.GetEnumerator() => EnumeratorCore(); + + int IReadOnlyCollection.Count => Length; + T IReadOnlyList.this[int index] => this[index]; + + public struct Builder(int maxLength) + { + public int Count { get; private set; } + private readonly T[] elements = maxLength == 0 ? [] : new T[maxLength]; + + public void Add(in T value) + { + elements[Count] = value; + Count++; + } + + public BasicArray Build() => new(elements, Count); + } +} diff --git a/eng/StackExchange.Redis.Build/FastHashGenerator.cs b/eng/StackExchange.Redis.Build/FastHashGenerator.cs deleted file mode 100644 index cdbc94ebe..000000000 --- a/eng/StackExchange.Redis.Build/FastHashGenerator.cs +++ /dev/null @@ -1,215 +0,0 @@ -using System.Buffers; -using System.Collections.Immutable; -using System.Reflection; -using System.Text; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace StackExchange.Redis.Build; - -[Generator(LanguageNames.CSharp)] -public class FastHashGenerator : IIncrementalGenerator -{ - public void Initialize(IncrementalGeneratorInitializationContext context) - { - var literals = context.SyntaxProvider - .CreateSyntaxProvider(Predicate, Transform) - .Where(pair => pair.Name is { Length: > 0 }) - .Collect(); - - context.RegisterSourceOutput(literals, Generate); - } - - private bool Predicate(SyntaxNode node, CancellationToken cancellationToken) - { - // looking for [FastHash] partial static class Foo { } - if (node is ClassDeclarationSyntax decl - && decl.Modifiers.Any(SyntaxKind.StaticKeyword) - && decl.Modifiers.Any(SyntaxKind.PartialKeyword)) - { - foreach (var attribList in decl.AttributeLists) - { - foreach (var attrib in attribList.Attributes) - { - if (attrib.Name.ToString() is "FastHashAttribute" or "FastHash") return true; - } - } - } - - return false; - } - - private static string GetName(INamedTypeSymbol type) - { - if (type.ContainingType is null) return type.Name; - var stack = new Stack(); - while (true) - { - stack.Push(type.Name); - if (type.ContainingType is null) break; - type = type.ContainingType; - } - var sb = new StringBuilder(stack.Pop()); - while (stack.Count != 0) - { - sb.Append('.').Append(stack.Pop()); - } - return sb.ToString(); - } - - private (string Namespace, string ParentType, string Name, string Value) Transform( - GeneratorSyntaxContext ctx, - CancellationToken cancellationToken) - { - // extract the name and value (defaults to name, but can be overridden via attribute) and the location - if (ctx.SemanticModel.GetDeclaredSymbol(ctx.Node) is not INamedTypeSymbol named) return default; - string ns = "", parentType = ""; - if (named.ContainingType is { } containingType) - { - parentType = GetName(containingType); - ns = containingType.ContainingNamespace.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); - } - else if (named.ContainingNamespace is { } containingNamespace) - { - ns = containingNamespace.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); - } - - string name = named.Name, value = ""; - foreach (var attrib in named.GetAttributes()) - { - if (attrib.AttributeClass?.Name == "FastHashAttribute") - { - if (attrib.ConstructorArguments.Length == 1) - { - if (attrib.ConstructorArguments[0].Value?.ToString() is { Length: > 0 } val) - { - value = val; - break; - } - } - } - } - - if (string.IsNullOrWhiteSpace(value)) - { - value = name.Replace("_", "-"); // if nothing explicit: infer from name - } - - return (ns, parentType, name, value); - } - - private string GetVersion() - { - var asm = GetType().Assembly; - if (asm.GetCustomAttributes(typeof(AssemblyFileVersionAttribute), false).FirstOrDefault() is - AssemblyFileVersionAttribute { Version: { Length: > 0 } } version) - { - return version.Version; - } - - return asm.GetName().Version?.ToString() ?? "??"; - } - - private void Generate( - SourceProductionContext ctx, - ImmutableArray<(string Namespace, string ParentType, string Name, string Value)> literals) - { - if (literals.IsDefaultOrEmpty) return; - - var sb = new StringBuilder("// ") - .AppendLine().Append("// ").Append(GetType().Name).Append(" v").Append(GetVersion()).AppendLine(); - - // lease a buffer that is big enough for the longest string - var buffer = ArrayPool.Shared.Rent( - Encoding.UTF8.GetMaxByteCount(literals.Max(l => l.Value.Length))); - int indent = 0; - - StringBuilder NewLine() => sb.AppendLine().Append(' ', indent * 4); - NewLine().Append("using System;"); - NewLine().Append("using StackExchange.Redis;"); - NewLine().Append("#pragma warning disable CS8981"); - foreach (var grp in literals.GroupBy(l => (l.Namespace, l.ParentType))) - { - NewLine(); - int braces = 0; - if (!string.IsNullOrWhiteSpace(grp.Key.Namespace)) - { - NewLine().Append("namespace ").Append(grp.Key.Namespace); - NewLine().Append("{"); - indent++; - braces++; - } - if (!string.IsNullOrWhiteSpace(grp.Key.ParentType)) - { - if (grp.Key.ParentType.Contains('.')) // nested types - { - foreach (var part in grp.Key.ParentType.Split('.')) - { - NewLine().Append("partial class ").Append(part); - NewLine().Append("{"); - indent++; - braces++; - } - } - else - { - NewLine().Append("partial class ").Append(grp.Key.ParentType); - NewLine().Append("{"); - indent++; - braces++; - } - } - - foreach (var literal in grp) - { - int len; - unsafe - { - fixed (byte* bPtr = buffer) // netstandard2.0 forces fallback API - { - fixed (char* cPtr = literal.Value) - { - len = Encoding.UTF8.GetBytes(cPtr, literal.Value.Length, bPtr, buffer.Length); - } - } - } - - // perform string escaping on the generated value (this includes the quotes, note) - var csValue = SyntaxFactory.LiteralExpression(SyntaxKind.StringLiteralExpression, SyntaxFactory.Literal(literal.Value)).ToFullString(); - - var hash = FastHash.Hash64(buffer.AsSpan(0, len)); - NewLine().Append("static partial class ").Append(literal.Name); - NewLine().Append("{"); - indent++; - NewLine().Append("public const int Length = ").Append(len).Append(';'); - NewLine().Append("public const long Hash = ").Append(hash).Append(';'); - NewLine().Append("public static ReadOnlySpan U8 => ").Append(csValue).Append("u8;"); - NewLine().Append("public const string Text = ").Append(csValue).Append(';'); - if (len <= 8) - { - // the hash enforces all the values - NewLine().Append("public static bool Is(long hash, in RawResult value) => hash == Hash && value.Payload.Length == Length;"); - NewLine().Append("public static bool Is(long hash, ReadOnlySpan value) => hash == Hash & value.Length == Length;"); - } - else - { - NewLine().Append("public static bool Is(long hash, in RawResult value) => hash == Hash && value.IsEqual(U8);"); - NewLine().Append("public static bool Is(long hash, ReadOnlySpan value) => hash == Hash && value.SequenceEqual(U8);"); - } - indent--; - NewLine().Append("}"); - } - - // handle any closing braces - while (braces-- > 0) - { - indent--; - NewLine().Append("}"); - } - } - - ArrayPool.Shared.Return(buffer); - ctx.AddSource("FastHash.generated.cs", sb.ToString()); - } -} diff --git a/eng/StackExchange.Redis.Build/FastHashGenerator.md b/eng/StackExchange.Redis.Build/FastHashGenerator.md deleted file mode 100644 index 7fc5103ae..000000000 --- a/eng/StackExchange.Redis.Build/FastHashGenerator.md +++ /dev/null @@ -1,64 +0,0 @@ -# FastHashGenerator - -Efficient matching of well-known short string tokens is a high-volume scenario, for example when matching RESP literals. - -The purpose of this generator is to interpret inputs like: - -``` c# -[FastHash] public static partial class bin { } -[FastHash] public static partial class f32 { } -``` - -Usually the token is inferred from the name; `[FastHash("real value")]` can be used if the token is not a valid identifier. -Underscore is replaced with hyphen, so a field called `my_token` has the default value `"my-token"`. -The generator demands *all* of `[FastHash] public static partial class`, and note that any *containing* types must -*also* be declared `partial`. - -The output is of the form: - -``` c# -static partial class bin -{ - public const int Length = 3; - public const long Hash = 7235938; - public static ReadOnlySpan U8 => @"bin"u8; - public static string Text => @"bin"; - public static bool Is(long hash, in RawResult value) => ... - public static bool Is(long hash, in ReadOnlySpan value) => ... -} -static partial class f32 -{ - public const int Length = 3; - public const long Hash = 3289958; - public static ReadOnlySpan U8 => @"f32"u8; - public const string Text = @"f32"; - public static bool Is(long hash, in RawResult value) => ... - public static bool Is(long hash, in ReadOnlySpan value) => ... -} -``` - -(this API is strictly an internal implementation detail, and can change at any time) - -This generated code allows for fast, efficient, and safe matching of well-known tokens, for example: - -``` c# -var key = ... -var hash = key.Hash64(); -switch (key.Length) -{ - case bin.Length when bin.Is(hash, key): - // handle bin - break; - case f32.Length when f32.Is(hash, key): - // handle f32 - break; -} -``` - -The switch on the `Length` is optional, but recommended - these low values can often be implemented (by the compiler) -as a simple jump-table, which is very fast. However, switching on the hash itself is also valid. All hash matches -must also perform a sequence equality check - the `Is(hash, value)` convenience method validates both hash and equality. - -Note that `switch` requires `const` values, hence why we use generated *types* rather than partial-properties -that emit an instance with the known values. Also, the `"..."u8` syntax emits a span which is awkward to store, but -easy to return via a property. diff --git a/eng/StackExchange.Redis.Build/StackExchange.Redis.Build.csproj b/eng/StackExchange.Redis.Build/StackExchange.Redis.Build.csproj index f875133ba..3cde6f5f6 100644 --- a/eng/StackExchange.Redis.Build/StackExchange.Redis.Build.csproj +++ b/eng/StackExchange.Redis.Build/StackExchange.Redis.Build.csproj @@ -12,8 +12,11 @@ - - FastHash.cs + + Shared/AsciiHash.cs + + + Shared/Experiments.cs diff --git a/src/RESPite/Buffers/CycleBuffer.cs b/src/RESPite/Buffers/CycleBuffer.cs new file mode 100644 index 000000000..f2488ff5d --- /dev/null +++ b/src/RESPite/Buffers/CycleBuffer.cs @@ -0,0 +1,751 @@ +using System.Buffers; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using RESPite.Internal; + +namespace RESPite.Buffers; + +/// +/// Manages the state for a based IO buffer. Unlike Pipe, +/// it is not intended for a separate producer-consumer - there is no thread-safety, and no +/// activation; it just handles the buffers. It is intended to be used as a mutable (non-readonly) +/// field in a type that performs IO; the internal state mutates - it should not be passed around. +/// +/// Notionally, there is an uncommitted area (write) and a committed area (read). Process: +/// - producer loop (*note no concurrency**) +/// - call to get a new scratch +/// - (write to that span) +/// - call to mark complete portions +/// - consumer loop (*note no concurrency**) +/// - call to see if there is a single-span chunk; otherwise +/// - call to get the multi-span chunk +/// - (process none, some, or all of that data) +/// - call to indicate how much data is no longer needed +/// Emphasis: no concurrency! This is intended for a single worker acting as both producer and consumer. +/// +/// There is a *lot* of validation in debug mode; we want to be super sure that we don't corrupt buffer state. +/// +[Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)] +public partial struct CycleBuffer +{ + #if TRACK_MEMORY + private static MemoryPool DefaultPool => MemoryTrackedPool.Shared; + #else + private static MemoryPool DefaultPool => MemoryPool.Shared; + #endif + + // note: if someone uses an uninitialized CycleBuffer (via default): that's a skills issue; git gud + public static CycleBuffer Create( + MemoryPool? pool = null, + int pageSize = DefaultPageSize, + ICycleBufferCallback? callback = null) + { + pool ??= DefaultPool; + if (pageSize <= 0) pageSize = DefaultPageSize; + if (pageSize > pool.MaxBufferSize) pageSize = pool.MaxBufferSize; + return new CycleBuffer(pool, pageSize, callback); + } + + private CycleBuffer(MemoryPool pool, int pageSize, ICycleBufferCallback? callback) + { + Pool = pool; + PageSize = pageSize; + _callback = callback; + leasedStart = -1; + } + + private const int DefaultPageSize = 8 * 1024; + + public int PageSize { get; } + public MemoryPool Pool { get; } + private readonly ICycleBufferCallback? _callback; + + private Segment? startSegment, endSegment; + + private int endSegmentCommitted, endSegmentLength; + private int leasedStart; + + public bool TryGetCommitted(out ReadOnlySpan span) + { + DebugAssertValid(); + if (!ReferenceEquals(startSegment, endSegment)) + { + span = default; + return false; + } + + span = startSegment is null ? default : startSegment.Memory.Span.Slice(start: 0, length: endSegmentCommitted); + return true; + } + + /// + /// Commits data written to buffers from , making it available for consumption + /// via . This compares to . + /// + public void Commit(int count) + { + DebugAssertValid(); + if (leasedStart < 0) + { + ThrowNoLease(); + } + + if (count <= 0) + { + if (count < 0) ThrowCount(); + return; + } + + var available = endSegmentLength - endSegmentCommitted; + if (count > available) ThrowCount(); + + var afterLeasedStart = endSegment!.StartTrimCount + endSegmentCommitted; + + if (leasedStart != afterLeasedStart) CopyDueToDiscardDuringWrite(count); + endSegmentCommitted += count; + DebugAssertValid(); + + static void ThrowCount() => throw new ArgumentOutOfRangeException(nameof(count)); + static void ThrowNoLease() => throw new InvalidOperationException("No open lease"); + } + + private void CopyDueToDiscardDuringWrite(int count) + { + var targetOffset = endSegment!.StartTrimCount + endSegmentCommitted; + if (targetOffset != leasedStart) + { + var full = endSegment.UntrimmedMemory.Span; + full.Slice(leasedStart, count) + .CopyTo(full.Slice(targetOffset, count)); + } + } + public bool CommittedIsEmpty => ReferenceEquals(startSegment, endSegment) & endSegmentCommitted == 0; + + /// + /// Marks committed data as fully consumed; it will no longer appear in later calls to . + /// + public void DiscardCommitted(int count) + { + DebugAssertValid(); + if (count == 0) return; + + // optimize for most common case, where we consume everything + if (ReferenceEquals(startSegment, endSegment) + & count == endSegmentCommitted + & count > 0) + { + /* + we are consuming all the data in the single segment; we can + just reset that segment back to full size and re-use as-is; + note that we also know that there must *be* a segment + for the count check to pass + */ + endSegmentCommitted = 0; + endSegmentLength = endSegment!.Untrim(expandBackwards: true); + DebugAssertValid(0); + DebugCounters.OnDiscardFull(count); + } + else + { + DiscardCommittedSlow(count); + } + } + + public void DiscardCommitted(long count) + { + DebugAssertValid(); + if (count == 0) return; + + // optimize for most common case, where we consume everything + if (ReferenceEquals(startSegment, endSegment) + & count == endSegmentCommitted + & count > 0) // checks sign *and* non-trimmed + { + // see for logic + endSegmentCommitted = 0; + endSegmentLength = endSegment!.Untrim(expandBackwards: true); + DebugAssertValid(0); + DebugCounters.OnDiscardFull(count); + } + else + { + DiscardCommittedSlow(count); + } + } + + private void DiscardCommittedSlow(long count) + { + DebugCounters.OnDiscardPartial(count); + DebugAssertValid(); +#if DEBUG + var originalLength = GetCommittedLength(); + var originalCount = count; + var expectedLength = originalLength - originalCount; + string blame = nameof(DiscardCommittedSlow); +#endif + while (count > 0) + { + DebugAssertValid(); + var segment = startSegment; + if (segment is null) break; + if (ReferenceEquals(segment, endSegment)) + { + // first==final==only segment + if (count == endSegmentCommitted) + { + endSegmentLength = startSegment!.Untrim(); + endSegmentCommitted = 0; // = untrimmed and unused +#if DEBUG + blame += ",full-final (t)"; +#endif + } + else + { + // discard from the start (note: don't need to compensate with writingCopyOffset until we untrim) + int count32 = checked((int)count); + segment.TrimStart(count32); + endSegmentLength -= count32; + endSegmentCommitted -= count32; +#if DEBUG + blame += ",partial-final"; +#endif + } + + count = 0; + break; + } + else if (count < segment.Length) + { + // multiple, but can take some (not all) of the first buffer +#if DEBUG + var len = segment.Length; +#endif + segment.TrimStart((int)count); + Debug.Assert(segment.Length > 0, "parial trim should have left non-empty segment"); +#if DEBUG + Debug.Assert(segment.Length == len - count, "trim failure"); + blame += ",partial-first"; +#endif + count = 0; + break; + } + else + { + // multiple; discard the entire first segment + count -= segment.Length; + startSegment = + segment.ResetAndGetNext(); // we already did a ref-check, so we know this isn't going past endSegment + endSegment!.AppendOrRecycle(segment, maxDepth: 2); + DebugAssertValid(); +#if DEBUG + blame += ",full-first"; +#endif + } + } + + if (count != 0) ThrowCount(); +#if DEBUG + DebugAssertValid(expectedLength, blame); + _ = originalLength; + _ = originalCount; +#endif + + [DoesNotReturn] + static void ThrowCount() => throw new ArgumentOutOfRangeException(nameof(count)); + } + + [Conditional("DEBUG")] + private void DebugAssertValid(long expectedCommittedLength, [CallerMemberName] string caller = "") + { + DebugAssertValid(); + var actual = GetCommittedLength(); + Debug.Assert( + expectedCommittedLength >= 0, + $"Expected committed length is just... wrong: {expectedCommittedLength} (from {caller})"); + Debug.Assert( + expectedCommittedLength == actual, + $"Committed length mismatch: expected {expectedCommittedLength}, got {actual} (from {caller})"); + } + + [Conditional("DEBUG")] + private void DebugAssertValid() + { + if (startSegment is null) + { + Debug.Assert( + endSegmentLength == 0 & endSegmentCommitted == 0, + "un-init state should be zero"); + return; + } + + Debug.Assert(endSegment is not null, "end segment must not be null if start segment exists"); + Debug.Assert( + endSegmentLength == endSegment!.Length, + $"end segment length is incorrect - expected {endSegmentLength}, got {endSegment.Length}"); + Debug.Assert(endSegmentCommitted <= endSegmentLength, $"end segment is over-committed - {endSegmentCommitted} of {endSegmentLength}"); + + // check running indices + startSegment?.DebugAssertValidChain(); + } + + public long GetCommittedLength() + { + if (ReferenceEquals(startSegment, endSegment)) + { + return endSegmentCommitted; + } + + // note that the start-segment is pre-trimmed; we don't need to account for an offset on the left + return (endSegment!.RunningIndex + endSegmentCommitted) - startSegment!.RunningIndex; + } + + /// + /// When used with , this means "any non-empty buffer". + /// + public const int GetAnything = 0; + + /// + /// When used with , this means "any full buffer". + /// + public const int GetFullPagesOnly = -1; + + public bool TryGetFirstCommittedSpan(int minBytes, out ReadOnlySpan span) + { + DebugAssertValid(); + if (TryGetFirstCommittedMemory(minBytes, out var memory)) + { + span = memory.Span; + return true; + } + + span = default; + return false; + } + + /// + /// The minLength arg: -ve means "full segments only" (useful when buffering outbound network data to avoid + /// packet fragmentation); otherwise, it is the minimum length we want. + /// + public bool TryGetFirstCommittedMemory(int minBytes, out ReadOnlyMemory memory) + { + if (minBytes == 0) minBytes = 1; // success always means "at least something" + DebugAssertValid(); + if (ReferenceEquals(startSegment, endSegment)) + { + // single page + var available = endSegmentCommitted; + if (available == 0) + { + // empty (includes uninitialized) + memory = default; + return false; + } + + memory = startSegment!.Memory; + var memLength = memory.Length; + if (available == memLength) + { + // full segment; is it enough to make the caller happy? + return available >= minBytes; + } + + // partial segment (and we know it isn't empty) + memory = memory.Slice(start: 0, length: available); + return available >= minBytes & minBytes > 0; // last check here applies the -ve logic + } + + // multi-page; hand out the first page (which is, by definition: full) + memory = startSegment!.Memory; + return memory.Length >= minBytes; + } + + /// + /// Note that this chain is invalidated by any other operations; no concurrency. + /// + public ReadOnlySequence GetAllCommitted() + { + if (ReferenceEquals(startSegment, endSegment)) + { + // single segment, fine + return startSegment is null + ? default + : new ReadOnlySequence(startSegment.Memory.Slice(start: 0, length: endSegmentCommitted)); + } + +#if PARSE_DETAIL + long length = GetCommittedLength(); +#endif + ReadOnlySequence ros = new(startSegment!, 0, endSegment!, endSegmentCommitted); +#if PARSE_DETAIL + Debug.Assert(ros.Length == length, $"length mismatch: calculated {length}, actual {ros.Length}"); +#endif + return ros; + } + + private Segment GetNextSegment() + { + DebugAssertValid(); + if (endSegment is not null) + { + endSegment.TrimEnd(endSegmentCommitted); + Debug.Assert(endSegment.Length == endSegmentCommitted, "trim failure"); + endSegmentLength = endSegmentCommitted; + DebugAssertValid(); + + // advertise the old page as available + _callback?.PageComplete(); + + var spare = endSegment.Next; + if (spare is not null) + { + // we already have a dangling segment; just update state + endSegment.DebugAssertValidChain(); + endSegment = spare; + endSegmentCommitted = 0; + endSegmentLength = spare.Length; + DebugAssertValid(); + return spare; + } + } + + Segment newSegment = Segment.Create(Pool.Rent(PageSize)); + if (endSegment is null) + { + // tabula rasa + endSegmentLength = newSegment.Length; + endSegment = startSegment = newSegment; + DebugAssertValid(); + return newSegment; + } + + endSegment.Append(newSegment); + endSegmentCommitted = 0; + endSegmentLength = newSegment.Length; + endSegment = newSegment; + DebugAssertValid(); + return newSegment; + } + + /// + /// Gets a scratch area for new data; this compares to . + /// + public Span GetUncommittedSpan(int hint = 0) + => GetUncommittedMemory(hint).Span; + + /// + /// Gets a scratch area for new data; this compares to . + /// + public Memory GetUncommittedMemory(int hint = 0) + { + DebugAssertValid(); + var segment = endSegment; + if (segment is not null) + { + leasedStart = segment.StartTrimCount + endSegmentCommitted; + var memory = segment.Memory; + if (endSegmentCommitted != 0) memory = memory.Slice(start: endSegmentCommitted); + if (hint <= 0) // allow anything non-empty + { + if (!memory.IsEmpty) return MemoryMarshal.AsMemory(memory); + } + else if (memory.Length >= Math.Min(hint, PageSize >> 2)) // respect the hint up to 1/4 of the page size + { + return MemoryMarshal.AsMemory(memory); + } + } + + // new segment, will always be entire + segment = GetNextSegment(); + leasedStart = segment.StartTrimCount + endSegmentCommitted; + Debug.Assert(leasedStart == 0, "should be zero for a new segment"); + return MemoryMarshal.AsMemory(segment.Memory); + } + + /// + /// This is the available unused buffer space, commonly used as the IO read-buffer to avoid + /// additional buffer-copy operations. + /// + public int UncommittedAvailable + { + get + { + DebugAssertValid(); + return endSegmentLength - endSegmentCommitted; + } + } + + private sealed class Segment : ReadOnlySequenceSegment + { + private Segment() { } + private IMemoryOwner _lease = NullLease.Instance; + private static Segment? _spare; + private Flags _flags; + + [Flags] + private enum Flags + { + None = 0, + StartTrim = 1 << 0, + EndTrim = 1 << 2, + } + + public static Segment Create(IMemoryOwner lease) + { + Debug.Assert(lease is not null, "null lease"); + var memory = lease!.Memory; + if (memory.IsEmpty) ThrowEmpty(); + + var obj = Interlocked.Exchange(ref _spare, null) ?? new(); + return obj.Init(lease, memory); + static void ThrowEmpty() => throw new InvalidOperationException("leased segment is empty"); + } + + private Segment Init(IMemoryOwner lease, Memory memory) + { + _lease = lease; + Memory = memory; + return this; + } + + public int Length => Memory.Length; + + public void Append(Segment next) + { + Debug.Assert(Next is null, "current segment already has a next"); + Debug.Assert(next.Next is null && next.RunningIndex == 0, "inbound next segment is already in a chain"); + next.RunningIndex = RunningIndex + Length; + Next = next; + DebugAssertValidChain(); + } + + private void ApplyChainDelta(int delta) + { + if (delta != 0) + { + var node = Next; + while (node is not null) + { + node.RunningIndex += delta; + node = node.Next; + } + } + } + + public void TrimEnd(int newLength) + { + var delta = Length - newLength; + if (delta != 0) + { + // buffer wasn't fully used; trim + _flags |= Flags.EndTrim; + Memory = Memory.Slice(0, newLength); + ApplyChainDelta(-delta); + DebugAssertValidChain(); + } + } + + public void TrimStart(int remove) + { + if (remove != 0) + { + _flags |= Flags.StartTrim; + Memory = Memory.Slice(start: remove); + RunningIndex += remove; // so that ROS length keeps working; note we *don't* need to adjust the chain + DebugAssertValidChain(); + StartTrimCount += remove; + } + } + + public new Segment? Next + { + get => (Segment?)base.Next; + private set => base.Next = value; + } + + public Segment? ResetAndGetNext() + { + var next = Next; + Next = null; + RunningIndex = 0; + _flags = Flags.None; + Memory = UntrimmedMemory; // reset, in case we trimmed it + DebugAssertValidChain(); + return next; + } + + public void Recycle() + { + var lease = _lease; + _lease = NullLease.Instance; + lease.Dispose(); + Next = null; + Memory = default; + RunningIndex = 0; + _flags = Flags.None; + Interlocked.Exchange(ref _spare, this); + DebugAssertValidChain(); + } + + private sealed class NullLease : IMemoryOwner + { + private NullLease() { } + public static readonly NullLease Instance = new NullLease(); + public void Dispose() { } + + public Memory Memory => default; + } + + public int StartTrimCount { get; private set; } + + /// + /// Get the full memory of the lease, before any trimming. + /// + public Memory UntrimmedMemory => _lease.Memory; + + /// + /// Undo any trimming, returning the new full capacity. + /// + public int Untrim(bool expandBackwards = false) + { + var fullMemory = UntrimmedMemory; + var fullLength = fullMemory.Length; + var delta = fullLength - Length; + if (delta != 0) + { + _flags &= ~(Flags.StartTrim | Flags.EndTrim); + Memory = fullMemory; + if (expandBackwards & RunningIndex >= delta) + { + // push our origin earlier; only valid if + // we're the first segment, otherwise + // we break someone-else's chain + RunningIndex -= delta; + } + else + { + // push everyone else later + ApplyChainDelta(delta); + } + + DebugAssertValidChain(); + } + + StartTrimCount = 0; + return fullLength; + } + + public bool StartTrimmed => (_flags & Flags.StartTrim) != 0; + public bool EndTrimmed => (_flags & Flags.EndTrim) != 0; + + [Conditional("DEBUG")] + public void DebugAssertValidChain([CallerMemberName] string blame = "") + { + var node = this; + var runningIndex = RunningIndex; + int index = 0; + while (node.Next is { } next) + { + index++; + var nextRunningIndex = runningIndex + node.Length; + if (nextRunningIndex != next.RunningIndex) ThrowRunningIndex(blame, index); + node = next; + runningIndex = nextRunningIndex; + static void ThrowRunningIndex(string blame, int index) => throw new InvalidOperationException( + $"Critical running index corruption in dangling chain, from '{blame}', segment {index}"); + } + } + + public void AppendOrRecycle(Segment segment, int maxDepth) + { + segment.Memory.DebugScramble(); + var node = this; + while (maxDepth-- > 0 && node is not null) + { + if (node.Next is null) // found somewhere to attach it + { + if (segment.Untrim() == 0) break; // turned out to be useless + segment.RunningIndex = node.RunningIndex + node.Length; + node.Next = segment; + return; + } + + node = node.Next; + } + + segment.Recycle(); + } + } + + /// + /// Discard all data and buffers. + /// + public void Release() + { + var node = startSegment; + startSegment = endSegment = null; + endSegmentCommitted = endSegmentLength = 0; + while (node is not null) + { + var next = node.Next; + node.Recycle(); + node = next; + } + } + + /// + /// Writes a value to the buffer; comparable to . + /// + public void Write(ReadOnlySpan value) + { + int srcLength = value.Length; + while (srcLength != 0) + { + var target = GetUncommittedSpan(hint: srcLength); + var tgtLength = target.Length; + if (tgtLength >= srcLength) + { + value.CopyTo(target); + Commit(srcLength); + return; + } + + value.Slice(0, tgtLength).CopyTo(target); + Commit(tgtLength); + value = value.Slice(tgtLength); + srcLength -= tgtLength; + } + } + + /// + /// Writes a value to the buffer; comparable to . + /// + public void Write(in ReadOnlySequence value) + { + if (value.IsSingleSegment) + { +#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1 + Write(value.FirstSpan); +#else + Write(value.First.Span); +#endif + } + else + { + WriteMultiSegment(ref this, in value); + } + + static void WriteMultiSegment(ref CycleBuffer @this, in ReadOnlySequence value) + { + foreach (var segment in value) + { +#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1 + @this.Write(value.FirstSpan); +#else + @this.Write(value.First.Span); +#endif + } + } + } +} diff --git a/src/RESPite/Buffers/ICycleBufferCallback.cs b/src/RESPite/Buffers/ICycleBufferCallback.cs new file mode 100644 index 000000000..9dcf1baa4 --- /dev/null +++ b/src/RESPite/Buffers/ICycleBufferCallback.cs @@ -0,0 +1,14 @@ +using System.Diagnostics.CodeAnalysis; + +namespace RESPite.Buffers; + +[Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)] +public interface ICycleBufferCallback +{ + /// + /// Notify that a page is available; this means that a consumer that wants + /// unflushed data can activate when pages are rotated, allowing large + /// payloads to be written concurrent with write. + /// + void PageComplete(); +} diff --git a/src/RESPite/Buffers/MemoryTrackedPool.cs b/src/RESPite/Buffers/MemoryTrackedPool.cs new file mode 100644 index 000000000..862910488 --- /dev/null +++ b/src/RESPite/Buffers/MemoryTrackedPool.cs @@ -0,0 +1,63 @@ +#if TRACK_MEMORY +using System.Buffers; +using System.Diagnostics.CodeAnalysis; + +namespace RESPite.Buffers; + +internal sealed class MemoryTrackedPool : MemoryPool +{ + // like MemoryPool, but tracks and reports double disposal via a custom memory manager, which + // allows all future use of a Memory to be tracked; contrast ArrayMemoryPool, which only tracks + // the initial fetch of .Memory from the lease + public override IMemoryOwner Rent(int minBufferSize = -1) => MemoryManager.Rent(minBufferSize); + + protected override void Dispose(bool disposing) + { + } + + // ReSharper disable once ArrangeModifiersOrder - you're wrong + public static new MemoryTrackedPool Shared { get; } = new(); + + public override int MaxBufferSize => MemoryPool.Shared.MaxBufferSize; + + private MemoryTrackedPool() + { + } + + private sealed class MemoryManager : MemoryManager + { + public static IMemoryOwner Rent(int minBufferSize = -1) => new MemoryManager(minBufferSize); + + private T[]? array; + private MemoryManager(int minBufferSize) + { + array = ArrayPool.Shared.Rent(Math.Max(64, minBufferSize)); + } + + private T[] CheckDisposed() + { + return array ?? Throw(); + [DoesNotReturn] + static T[] Throw() => throw new ObjectDisposedException("Use-after-free of Memory-" + typeof(T).Name); + } + + public override MemoryHandle Pin(int elementIndex = 0) => throw new NotSupportedException(nameof(Pin)); + + public override void Unpin() => throw new NotSupportedException(nameof(Unpin)); + + public override Span GetSpan() => CheckDisposed(); + + protected override bool TryGetArray(out ArraySegment segment) + { + segment = new ArraySegment(CheckDisposed()); + return true; + } + + protected override void Dispose(bool disposing) + { + var arr = Interlocked.Exchange(ref array, null); + if (arr is not null) ArrayPool.Shared.Return(arr); + } + } +} +#endif diff --git a/src/RESPite/Internal/BlockBuffer.cs b/src/RESPite/Internal/BlockBuffer.cs new file mode 100644 index 000000000..752d74c8d --- /dev/null +++ b/src/RESPite/Internal/BlockBuffer.cs @@ -0,0 +1,341 @@ +using System.Buffers; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace RESPite.Internal; + +internal abstract partial class BlockBufferSerializer +{ + internal sealed class BlockBuffer : MemoryManager + { + private BlockBuffer(BlockBufferSerializer parent, int minCapacity) + { + _arrayPool = parent._arrayPool; + _array = _arrayPool.Rent(minCapacity); + DebugCounters.OnBufferCapacity(_array.Length); +#if DEBUG + _parent = parent; + parent.DebugBufferCreated(); +#endif + } + + private int _refCount = 1; + private int _finalizedOffset, _writeOffset; + private readonly ArrayPool _arrayPool; + private byte[] _array; +#if DEBUG + private int _finalizedCount; + private BlockBufferSerializer _parent; +#endif + + public override string ToString() => +#if DEBUG + $"{_finalizedCount} messages; " + +#endif + $"{_finalizedOffset} finalized bytes; writing: {NonFinalizedData.Length} bytes, {Available} available; observers: {_refCount}"; + + // only used when filling; _buffer should be non-null + private int Available => _array.Length - _writeOffset; + public Memory UncommittedMemory => _array.AsMemory(_writeOffset); + public Span UncommittedSpan => _array.AsSpan(_writeOffset); + + // decrease ref-count; dispose if necessary + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Release() + { + if (Interlocked.Decrement(ref _refCount) <= 0) Recycle(); + } + + public void AddRef() + { + if (!TryAddRef()) Throw(); + static void Throw() => throw new ObjectDisposedException(nameof(BlockBuffer)); + } + + public bool TryAddRef() + { + int count; + do + { + count = Volatile.Read(ref _refCount); + if (count <= 0) return false; + } + // repeat until we can successfully swap/incr + while (Interlocked.CompareExchange(ref _refCount, count + 1, count) != count); + + return true; + } + + [MethodImpl(MethodImplOptions.NoInlining)] // called rarely vs Dispose + private void Recycle() + { + var count = Volatile.Read(ref _refCount); + if (count == 0) + { + _array.DebugScramble(); +#if DEBUG + GC.SuppressFinalize(this); // only have a finalizer in debug + _parent.DebugBufferRecycled(_array.Length); +#endif + _arrayPool.Return(_array); + _array = []; + } + + Debug.Assert(count == 0, $"over-disposal? count={count}"); + } + +#if DEBUG +#pragma warning disable CA2015 // Adding a finalizer to a type derived from MemoryManager may permit memory to be freed while it is still in use by a Span + // (the above is fine because we don't actually release anything - just a counter) + ~BlockBuffer() + { + _parent.DebugBufferLeaked(); + DebugCounters.OnBufferLeaked(); + } +#pragma warning restore CA2015 +#endif + + public static BlockBuffer GetBuffer(BlockBufferSerializer parent, int sizeHint) + { + // note this isn't an actual "max", just a max of what we guarantee; we give the caller + // whatever is left in the buffer; the clamped hint just decides whether we need a *new* buffer + const int MinSize = 16, MaxSize = 128; + sizeHint = Math.Min(Math.Max(sizeHint, MinSize), MaxSize); + + var buffer = parent.Buffer; // most common path is "exists, with enough data" + return buffer is not null && buffer.AvailableWithResetIfUseful() >= sizeHint + ? buffer + : GetBufferSlow(parent, sizeHint); + } + + // would it be useful and possible to reset? i.e. if all finalized chunks have been returned, + private int AvailableWithResetIfUseful() + { + if (_finalizedOffset != 0 // at least some chunks have been finalized + && Volatile.Read(ref _refCount) == 1 // all finalized chunks returned + & _writeOffset == _finalizedOffset) // we're not in the middle of serializing something new + { + _writeOffset = _finalizedOffset = 0; // swipe left + } + + return _array.Length - _writeOffset; + } + + private static BlockBuffer GetBufferSlow(BlockBufferSerializer parent, int minBytes) + { + // note clamp on size hint has already been applied + const int DefaultBufferSize = 2048; + var buffer = parent.Buffer; + if (buffer is null) + { + // first buffer + return parent.Buffer = new BlockBuffer(parent, DefaultBufferSize); + } + + Debug.Assert(minBytes > buffer.Available, "existing buffer has capacity - why are we here?"); + + if (buffer.TryResizeFor(minBytes)) + { + Debug.Assert(buffer.Available >= minBytes); + return buffer; + } + + // We've tried reset and resize - no more tricks; we need to move to a new buffer, starting with a + // capacity for any existing data in this message, plus the new chunk we're adding. + var nonFinalizedBytes = buffer.NonFinalizedData; + var newBuffer = new BlockBuffer(parent, Math.Max(nonFinalizedBytes.Length + minBytes, DefaultBufferSize)); + + // copy the existing message data, if any (the previous message might have finished near the + // boundary, in which case we might not have written anything yet) + newBuffer.CopyFrom(nonFinalizedBytes); + Debug.Assert(newBuffer.Available >= minBytes, "should have requested extra capacity"); + + // the ~emperor~ buffer is dead; long live the ~emperor~ buffer + parent.Buffer = newBuffer; + buffer.MarkComplete(parent); + return newBuffer; + } + + // used for elective reset (rather than "because we ran out of space") + public static void Clear(BlockBufferSerializer parent) + { + if (parent.Buffer is { } buffer) + { + parent.Buffer = null; + buffer.MarkComplete(parent); + } + } + + public static ReadOnlyMemory RetainCurrent(BlockBufferSerializer parent) + { + if (parent.Buffer is { } buffer && buffer._finalizedOffset != 0) + { + parent.Buffer = null; + buffer.AddRef(); + return buffer.CreateMemory(0, buffer._finalizedOffset); + } + // nothing useful to detach! + return default; + } + + private void MarkComplete(BlockBufferSerializer parent) + { + // record that the old buffer no longer logically has any non-committed bytes (mostly just for ToString()) + _writeOffset = _finalizedOffset; + Debug.Assert(IsNonCommittedEmpty); + + // see if the caller wants to take ownership of the segment + if (_finalizedOffset != 0 && !parent.ClaimSegment(CreateMemory(0, _finalizedOffset))) + { + Release(); // decrement the observer + } +#if DEBUG + DebugCounters.OnBufferCompleted(_finalizedCount, _finalizedOffset); +#endif + } + + private void CopyFrom(Span source) + { + source.CopyTo(UncommittedSpan); + _writeOffset += source.Length; + } + + private Span NonFinalizedData => _array.AsSpan( + _finalizedOffset, _writeOffset - _finalizedOffset); + + private bool TryResizeFor(int extraBytes) + { + if (_finalizedOffset == 0 & // we can only do this if there are no other messages in the buffer + Volatile.Read(ref _refCount) == 1) // and no-one else is looking (we already tried reset) + { + // we're already on the boundary - don't scrimp; just do the math from the end of the buffer + byte[] newArray = _arrayPool.Rent(_array.Length + extraBytes); + DebugCounters.OnBufferCapacity(newArray.Length - _array.Length); // account for extra only + + // copy the existing data (we always expect some, since we've clamped extraBytes to be + // much smaller than the default buffer size) + NonFinalizedData.CopyTo(newArray); + _array.DebugScramble(); + _arrayPool.Return(_array); + _array = newArray; + return true; + } + + return false; + } + + public static void Advance(BlockBufferSerializer parent, int count) + { + if (count == 0) return; + if (count < 0) ThrowOutOfRange(); + var buffer = parent.Buffer; + if (buffer is null || buffer.Available < count) ThrowOutOfRange(); + buffer._writeOffset += count; + + [DoesNotReturn] + static void ThrowOutOfRange() => throw new ArgumentOutOfRangeException(nameof(count)); + } + + public void RevertUnfinalized(BlockBufferSerializer parent) + { + // undo any writes (something went wrong during serialize) + _finalizedOffset = _writeOffset; + } + + private ReadOnlyMemory FinalizeBlock() + { + var length = _writeOffset - _finalizedOffset; + Debug.Assert(length > 0, "already checked this in FinalizeMessage!"); + var chunk = CreateMemory(_finalizedOffset, length); + _finalizedOffset = _writeOffset; // move the write head +#if DEBUG + _finalizedCount++; + _parent.DebugMessageFinalized(length); +#endif + Interlocked.Increment(ref _refCount); // add an observer + return chunk; + } + + private bool IsNonCommittedEmpty => _finalizedOffset == _writeOffset; + + public static ReadOnlyMemory FinalizeMessage(BlockBufferSerializer parent) + { + var buffer = parent.Buffer; + if (buffer is null || buffer.IsNonCommittedEmpty) + { +#if DEBUG // still count it for logging purposes + if (buffer is not null) buffer._finalizedCount++; + parent.DebugMessageFinalized(0); +#endif + return default; + } + + return buffer.FinalizeBlock(); + } + + // MemoryManager pieces + protected override void Dispose(bool disposing) + { + if (disposing) Release(); + } + + public override Span GetSpan() => _array; + public int Length => _array.Length; + + // base version is CreateMemory(GetSpan().Length); avoid that GetSpan() + public override Memory Memory => CreateMemory(_array.Length); + + public override unsafe MemoryHandle Pin(int elementIndex = 0) + { + // We *could* be cute and use a shared pin - but that's a *lot* + // of work (synchronization), requires extra storage, and for an + // API that is very unlikely; hence: we'll use per-call GC pins. + GCHandle handle = GCHandle.Alloc(_array, GCHandleType.Pinned); + DebugCounters.OnBufferPinned(); // prove how unlikely this is + byte* ptr = (byte*)handle.AddrOfPinnedObject(); + // note no IPinnable in the MemoryHandle; + return new MemoryHandle(ptr + elementIndex, handle); + } + + // This would only be called if we passed out a MemoryHandle with ourselves + // as IPinnable (in Pin), which: we don't. + public override void Unpin() => throw new NotSupportedException(); + + protected override bool TryGetArray(out ArraySegment segment) + { + segment = new ArraySegment(_array); + return true; + } + + internal static void Release(in ReadOnlySequence request) + { + if (request.IsSingleSegment) + { + if (MemoryMarshal.TryGetMemoryManager( + request.First, out var block)) + { + block.Release(); + } + } + else + { + ReleaseMultiBlock(in request); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static void ReleaseMultiBlock(in ReadOnlySequence request) + { + foreach (var segment in request) + { + if (MemoryMarshal.TryGetMemoryManager( + segment, out var block)) + { + block.Release(); + } + } + } + } + } +} diff --git a/src/RESPite/Internal/BlockBufferSerializer.cs b/src/RESPite/Internal/BlockBufferSerializer.cs new file mode 100644 index 000000000..b8b3649cd --- /dev/null +++ b/src/RESPite/Internal/BlockBufferSerializer.cs @@ -0,0 +1,96 @@ +using System.Buffers; +using System.Diagnostics; + +namespace RESPite.Internal; + +/// +/// Provides abstracted access to a buffer-writing API. Conveniently, we only give the caller +/// RespWriter - which they cannot export (ref-type), thus we never actually give the +/// public caller our IBufferWriter{byte}. Likewise, note that serialization is synchronous, +/// i.e. never switches thread during an operation. This gives us quite a bit of flexibility. +/// There are two main uses of BlockBufferSerializer: +/// 1. thread-local: ambient, used for random messages so that each thread is quietly packing +/// a thread-specific buffer; zero concurrency because of [ThreadStatic] hackery. +/// 2. batching: RespBatch hosts a serializer that reflects the batch we're building; successive +/// commands in the same batch are written adjacently in a shared buffer - we explicitly +/// detect and reject concurrency attempts in a batch (which is fair: a batch has order). +/// +internal abstract partial class BlockBufferSerializer(ArrayPool? arrayPool = null) : IBufferWriter +{ + private readonly ArrayPool _arrayPool = arrayPool ?? ArrayPool.Shared; + private protected abstract BlockBuffer? Buffer { get; set; } + + Memory IBufferWriter.GetMemory(int sizeHint) => BlockBuffer.GetBuffer(this, sizeHint).UncommittedMemory; + + Span IBufferWriter.GetSpan(int sizeHint) => BlockBuffer.GetBuffer(this, sizeHint).UncommittedSpan; + + void IBufferWriter.Advance(int count) => BlockBuffer.Advance(this, count); + + public virtual void Clear() => BlockBuffer.Clear(this); + + internal virtual ReadOnlySequence Flush() => throw new NotSupportedException(); + + /* + public virtual ReadOnlyMemory Serialize( + RespCommandMap? commandMap, + ReadOnlySpan command, + in TRequest request, + IRespFormatter formatter) +#if NET9_0_OR_GREATER + where TRequest : allows ref struct +#endif + { + try + { + var writer = new RespWriter(this); + writer.CommandMap = commandMap; + formatter.Format(command, ref writer, request); + writer.Flush(); + return BlockBuffer.FinalizeMessage(this); + } + catch + { + Buffer?.RevertUnfinalized(this); + throw; + } + } + */ + + internal void Revert() => Buffer?.RevertUnfinalized(this); + + protected virtual bool ClaimSegment(ReadOnlyMemory segment) => false; + +#if DEBUG + private int _countAdded, _countRecycled, _countLeaked, _countMessages; + private long _countMessageBytes; + public int CountLeaked => Volatile.Read(ref _countLeaked); + public int CountRecycled => Volatile.Read(ref _countRecycled); + public int CountAdded => Volatile.Read(ref _countAdded); + public int CountMessages => Volatile.Read(ref _countMessages); + public long CountMessageBytes => Volatile.Read(ref _countMessageBytes); + + [Conditional("DEBUG")] + private void DebugBufferLeaked() => Interlocked.Increment(ref _countLeaked); + + [Conditional("DEBUG")] + private void DebugBufferRecycled(int length) + { + Interlocked.Increment(ref _countRecycled); + DebugCounters.OnBufferRecycled(length); + } + + [Conditional("DEBUG")] + private void DebugBufferCreated() + { + Interlocked.Increment(ref _countAdded); + DebugCounters.OnBufferCreated(); + } + + [Conditional("DEBUG")] + private void DebugMessageFinalized(int bytes) + { + Interlocked.Increment(ref _countMessages); + Interlocked.Add(ref _countMessageBytes, bytes); + } +#endif +} diff --git a/src/RESPite/Internal/DebugCounters.cs b/src/RESPite/Internal/DebugCounters.cs new file mode 100644 index 000000000..2ed742a84 --- /dev/null +++ b/src/RESPite/Internal/DebugCounters.cs @@ -0,0 +1,163 @@ +using System.Diagnostics; + +namespace RESPite.Internal; + +internal partial class DebugCounters +{ +#if DEBUG + private static int + _tallySyncReadCount, + _tallyAsyncReadCount, + _tallyAsyncReadInlineCount, + _tallyDiscardFullCount, + _tallyDiscardPartialCount, + _tallyBufferCreatedCount, + _tallyBufferRecycledCount, + _tallyBufferMessageCount, + _tallyBufferPinCount, + _tallyBufferLeakCount; + + private static long + _tallyReadBytes, + _tallyDiscardAverage, + _tallyBufferMessageBytes, + _tallyBufferRecycledBytes, + _tallyBufferMaxOutstandingBytes, + _tallyBufferTotalBytes; +#endif + + [Conditional("DEBUG")] + public static void OnDiscardFull(long count) + { +#if DEBUG + if (count > 0) + { + Interlocked.Increment(ref _tallyDiscardFullCount); + EstimatedMovingRangeAverage(ref _tallyDiscardAverage, count); + } +#endif + } + + [Conditional("DEBUG")] + public static void OnDiscardPartial(long count) + { +#if DEBUG + if (count > 0) + { + Interlocked.Increment(ref _tallyDiscardPartialCount); + EstimatedMovingRangeAverage(ref _tallyDiscardAverage, count); + } +#endif + } + + [Conditional("DEBUG")] + internal static void OnAsyncRead(int bytes, bool inline) + { +#if DEBUG + Interlocked.Increment(ref inline ? ref _tallyAsyncReadInlineCount : ref _tallyAsyncReadCount); + if (bytes > 0) Interlocked.Add(ref _tallyReadBytes, bytes); +#endif + } + + [Conditional("DEBUG")] + internal static void OnSyncRead(int bytes) + { +#if DEBUG + Interlocked.Increment(ref _tallySyncReadCount); + if (bytes > 0) Interlocked.Add(ref _tallyReadBytes, bytes); +#endif + } + + [Conditional("DEBUG")] + public static void OnBufferCreated() + { +#if DEBUG + Interlocked.Increment(ref _tallyBufferCreatedCount); +#endif + } + + [Conditional("DEBUG")] + public static void OnBufferRecycled(int messageBytes) + { +#if DEBUG + Interlocked.Increment(ref _tallyBufferRecycledCount); + var now = Interlocked.Add(ref _tallyBufferRecycledBytes, messageBytes); + var outstanding = Volatile.Read(ref _tallyBufferMessageBytes) - now; + + while (true) + { + var oldOutstanding = Volatile.Read(ref _tallyBufferMaxOutstandingBytes); + // loop until either it isn't an increase, or we successfully perform + // the swap + if (outstanding <= oldOutstanding + || Interlocked.CompareExchange( + ref _tallyBufferMaxOutstandingBytes, + outstanding, + oldOutstanding) == oldOutstanding) break; + } +#endif + } + + [Conditional("DEBUG")] + public static void OnBufferCompleted(int messageCount, int messageBytes) + { +#if DEBUG + Interlocked.Add(ref _tallyBufferMessageCount, messageCount); + Interlocked.Add(ref _tallyBufferMessageBytes, messageBytes); +#endif + } + + [Conditional("DEBUG")] + public static void OnBufferCapacity(int bytes) + { +#if DEBUG + Interlocked.Add(ref _tallyBufferTotalBytes, bytes); +#endif + } + + [Conditional("DEBUG")] + public static void OnBufferPinned() + { +#if DEBUG + Interlocked.Increment(ref _tallyBufferPinCount); +#endif + } + + [Conditional("DEBUG")] + public static void OnBufferLeaked() + { +#if DEBUG + Interlocked.Increment(ref _tallyBufferLeakCount); +#endif + } + +#if DEBUG + private static void EstimatedMovingRangeAverage(ref long field, long value) + { + var oldValue = Volatile.Read(ref field); + var delta = (value - oldValue) >> 3; // is is a 7:1 old:new EMRA, using integer/bit math (alplha=0.125) + if (delta != 0) Interlocked.Add(ref field, delta); + // note: strictly conflicting concurrent calls can skew the value incorrectly; this is, however, + // preferable to getting into a CEX squabble or requiring a lock - it is debug-only and just useful data + } + + public int SyncReadCount { get; } = Interlocked.Exchange(ref _tallySyncReadCount, 0); + public int AsyncReadCount { get; } = Interlocked.Exchange(ref _tallyAsyncReadCount, 0); + public int AsyncReadInlineCount { get; } = Interlocked.Exchange(ref _tallyAsyncReadInlineCount, 0); + public long ReadBytes { get; } = Interlocked.Exchange(ref _tallyReadBytes, 0); + + public long DiscardAverage { get; } = Interlocked.Exchange(ref _tallyDiscardAverage, 32); + public int DiscardFullCount { get; } = Interlocked.Exchange(ref _tallyDiscardFullCount, 0); + public int DiscardPartialCount { get; } = Interlocked.Exchange(ref _tallyDiscardPartialCount, 0); + + public int BufferCreatedCount { get; } = Interlocked.Exchange(ref _tallyBufferCreatedCount, 0); + public int BufferRecycledCount { get; } = Interlocked.Exchange(ref _tallyBufferRecycledCount, 0); + public long BufferRecycledBytes { get; } = Interlocked.Exchange(ref _tallyBufferRecycledBytes, 0); + public long BufferMaxOutstandingBytes { get; } = Interlocked.Exchange(ref _tallyBufferMaxOutstandingBytes, 0); + public int BufferMessageCount { get; } = Interlocked.Exchange(ref _tallyBufferMessageCount, 0); + public long BufferMessageBytes { get; } = Interlocked.Exchange(ref _tallyBufferMessageBytes, 0); + public long BufferTotalBytes { get; } = Interlocked.Exchange(ref _tallyBufferTotalBytes, 0); + public int BufferPinCount { get; } = Interlocked.Exchange(ref _tallyBufferPinCount, 0); + public int BufferLeakCount { get; } = Interlocked.Exchange(ref _tallyBufferLeakCount, 0); +#endif +} diff --git a/src/RESPite/Internal/Raw.cs b/src/RESPite/Internal/Raw.cs new file mode 100644 index 000000000..65d0c5059 --- /dev/null +++ b/src/RESPite/Internal/Raw.cs @@ -0,0 +1,138 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; + +#if NETCOREAPP3_0_OR_GREATER +using System.Runtime.Intrinsics; +using System.Runtime.Intrinsics.X86; +#endif + +namespace RESPite.Internal; + +/// +/// Pre-computed payload fragments, for high-volume scenarios / common values. +/// +/// +/// CPU-endianness applies here; we can't just use "const" - however, modern JITs treat "static readonly" *almost* the same as "const", so: meh. +/// +internal static class Raw +{ + public static ulong Create64(ReadOnlySpan bytes, int length) + { + if (length != bytes.Length) + { + throw new ArgumentException($"Length check failed: {length} vs {bytes.Length}, value: {RespConstants.UTF8.GetString(bytes)}", nameof(length)); + } + if (length < 0 || length > sizeof(ulong)) + { + throw new ArgumentOutOfRangeException(nameof(length), $"Invalid length {length} - must be 0-{sizeof(ulong)}"); + } + + // this *will* be aligned; this approach intentionally chosen for parity with write + Span scratch = stackalloc byte[sizeof(ulong)]; + if (length != sizeof(ulong)) scratch.Slice(length).Clear(); + bytes.CopyTo(scratch); + return Unsafe.ReadUnaligned(ref MemoryMarshal.GetReference(scratch)); + } + + public static uint Create32(ReadOnlySpan bytes, int length) + { + if (length != bytes.Length) + { + throw new ArgumentException($"Length check failed: {length} vs {bytes.Length}, value: {RespConstants.UTF8.GetString(bytes)}", nameof(length)); + } + if (length < 0 || length > sizeof(uint)) + { + throw new ArgumentOutOfRangeException(nameof(length), $"Invalid length {length} - must be 0-{sizeof(uint)}"); + } + + // this *will* be aligned; this approach intentionally chosen for parity with write + Span scratch = stackalloc byte[sizeof(uint)]; + if (length != sizeof(uint)) scratch.Slice(length).Clear(); + bytes.CopyTo(scratch); + return Unsafe.ReadUnaligned(ref MemoryMarshal.GetReference(scratch)); + } + + public static ulong BulkStringEmpty_6 = Create64("$0\r\n\r\n"u8, 6); + + public static ulong BulkStringInt32_M1_8 = Create64("$2\r\n-1\r\n"u8, 8); + public static ulong BulkStringInt32_0_7 = Create64("$1\r\n0\r\n"u8, 7); + public static ulong BulkStringInt32_1_7 = Create64("$1\r\n1\r\n"u8, 7); + public static ulong BulkStringInt32_2_7 = Create64("$1\r\n2\r\n"u8, 7); + public static ulong BulkStringInt32_3_7 = Create64("$1\r\n3\r\n"u8, 7); + public static ulong BulkStringInt32_4_7 = Create64("$1\r\n4\r\n"u8, 7); + public static ulong BulkStringInt32_5_7 = Create64("$1\r\n5\r\n"u8, 7); + public static ulong BulkStringInt32_6_7 = Create64("$1\r\n6\r\n"u8, 7); + public static ulong BulkStringInt32_7_7 = Create64("$1\r\n7\r\n"u8, 7); + public static ulong BulkStringInt32_8_7 = Create64("$1\r\n8\r\n"u8, 7); + public static ulong BulkStringInt32_9_7 = Create64("$1\r\n9\r\n"u8, 7); + public static ulong BulkStringInt32_10_8 = Create64("$2\r\n10\r\n"u8, 8); + + public static ulong BulkStringPrefix_M1_5 = Create64("$-1\r\n"u8, 5); + public static uint BulkStringPrefix_0_4 = Create32("$0\r\n"u8, 4); + public static uint BulkStringPrefix_1_4 = Create32("$1\r\n"u8, 4); + public static uint BulkStringPrefix_2_4 = Create32("$2\r\n"u8, 4); + public static uint BulkStringPrefix_3_4 = Create32("$3\r\n"u8, 4); + public static uint BulkStringPrefix_4_4 = Create32("$4\r\n"u8, 4); + public static uint BulkStringPrefix_5_4 = Create32("$5\r\n"u8, 4); + public static uint BulkStringPrefix_6_4 = Create32("$6\r\n"u8, 4); + public static uint BulkStringPrefix_7_4 = Create32("$7\r\n"u8, 4); + public static uint BulkStringPrefix_8_4 = Create32("$8\r\n"u8, 4); + public static uint BulkStringPrefix_9_4 = Create32("$9\r\n"u8, 4); + public static ulong BulkStringPrefix_10_5 = Create64("$10\r\n"u8, 5); + + public static ulong ArrayPrefix_M1_5 = Create64("*-1\r\n"u8, 5); + public static uint ArrayPrefix_0_4 = Create32("*0\r\n"u8, 4); + public static uint ArrayPrefix_1_4 = Create32("*1\r\n"u8, 4); + public static uint ArrayPrefix_2_4 = Create32("*2\r\n"u8, 4); + public static uint ArrayPrefix_3_4 = Create32("*3\r\n"u8, 4); + public static uint ArrayPrefix_4_4 = Create32("*4\r\n"u8, 4); + public static uint ArrayPrefix_5_4 = Create32("*5\r\n"u8, 4); + public static uint ArrayPrefix_6_4 = Create32("*6\r\n"u8, 4); + public static uint ArrayPrefix_7_4 = Create32("*7\r\n"u8, 4); + public static uint ArrayPrefix_8_4 = Create32("*8\r\n"u8, 4); + public static uint ArrayPrefix_9_4 = Create32("*9\r\n"u8, 4); + public static ulong ArrayPrefix_10_5 = Create64("*10\r\n"u8, 5); + +#if NETCOREAPP3_0_OR_GREATER + private static uint FirstAndLast(char first, char last) + { + Debug.Assert(first < 128 && last < 128, "ASCII please"); + Span scratch = [(byte)first, 0, 0, (byte)last]; + // this *will* be aligned; this approach intentionally chosen for how we read + return Unsafe.ReadUnaligned(ref MemoryMarshal.GetReference(scratch)); + } + + public const int CommonRespIndex_Success = 0; + public const int CommonRespIndex_SingleDigitInteger = 1; + public const int CommonRespIndex_DoubleDigitInteger = 2; + public const int CommonRespIndex_SingleDigitString = 3; + public const int CommonRespIndex_DoubleDigitString = 4; + public const int CommonRespIndex_SingleDigitArray = 5; + public const int CommonRespIndex_DoubleDigitArray = 6; + public const int CommonRespIndex_Error = 7; + + public static readonly Vector256 CommonRespPrefixes = Vector256.Create( + FirstAndLast('+', '\r'), // success +OK\r\n + FirstAndLast(':', '\n'), // single-digit integer :4\r\n + FirstAndLast(':', '\r'), // double-digit integer :42\r\n + FirstAndLast('$', '\n'), // 0-9 char string $0\r\n\r\n + FirstAndLast('$', '\r'), // null/10-99 char string $-1\r\n or $10\r\nABCDEFGHIJ\r\n + FirstAndLast('*', '\n'), // 0-9 length array *0\r\n + FirstAndLast('*', '\r'), // null/10-99 length array *-1\r\n or *10\r\n:0\r\n:0\r\n:0\r\n:0\r\n:0\r\n:0\r\n:0\r\n:0\r\n:0\r\n:0\r\n + FirstAndLast('-', 'R')); // common errors -ERR something bad happened + + public static readonly Vector256 FirstLastMask = CreateUInt32(0xFF0000FF); + + private static Vector256 CreateUInt32(uint value) + { +#if NET7_0_OR_GREATER + return Vector256.Create(value); +#else + return Vector256.Create(value, value, value, value, value, value, value, value); +#endif + } + +#endif +} diff --git a/src/RESPite/Internal/RespConstants.cs b/src/RESPite/Internal/RespConstants.cs new file mode 100644 index 000000000..accb8400b --- /dev/null +++ b/src/RESPite/Internal/RespConstants.cs @@ -0,0 +1,53 @@ +using System.Buffers.Binary; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +// ReSharper disable InconsistentNaming +namespace RESPite.Internal; + +internal static class RespConstants +{ + public static readonly UTF8Encoding UTF8 = new(false); + + public static ReadOnlySpan CrlfBytes => "\r\n"u8; + + public static readonly ushort CrLfUInt16 = UnsafeCpuUInt16(CrlfBytes); + + public static ReadOnlySpan OKBytes_LC => "ok"u8; + public static ReadOnlySpan OKBytes => "OK"u8; + public static readonly ushort OKUInt16 = UnsafeCpuUInt16(OKBytes); + public static readonly ushort OKUInt16_LC = UnsafeCpuUInt16(OKBytes_LC); + + public static readonly uint BulkStringStreaming = UnsafeCpuUInt32("$?\r\n"u8); + public static readonly uint BulkStringNull = UnsafeCpuUInt32("$-1\r"u8); + + public static readonly uint ArrayStreaming = UnsafeCpuUInt32("*?\r\n"u8); + public static readonly uint ArrayNull = UnsafeCpuUInt32("*-1\r"u8); + + public static ushort UnsafeCpuUInt16(ReadOnlySpan bytes) + => Unsafe.ReadUnaligned(ref MemoryMarshal.GetReference(bytes)); + public static ushort UnsafeCpuUInt16(ReadOnlySpan bytes, int offset) + => Unsafe.ReadUnaligned(ref Unsafe.Add(ref MemoryMarshal.GetReference(bytes), offset)); + public static byte UnsafeCpuByte(ReadOnlySpan bytes, int offset) + => Unsafe.Add(ref MemoryMarshal.GetReference(bytes), offset); + public static uint UnsafeCpuUInt32(ReadOnlySpan bytes) + => Unsafe.ReadUnaligned(ref MemoryMarshal.GetReference(bytes)); + public static uint UnsafeCpuUInt32(ReadOnlySpan bytes, int offset) + => Unsafe.ReadUnaligned(ref Unsafe.Add(ref MemoryMarshal.GetReference(bytes), offset)); + public static ulong UnsafeCpuUInt64(ReadOnlySpan bytes) + => Unsafe.ReadUnaligned(ref MemoryMarshal.GetReference(bytes)); + public static ushort CpuUInt16(ushort bigEndian) + => BitConverter.IsLittleEndian ? BinaryPrimitives.ReverseEndianness(bigEndian) : bigEndian; + public static uint CpuUInt32(uint bigEndian) + => BitConverter.IsLittleEndian ? BinaryPrimitives.ReverseEndianness(bigEndian) : bigEndian; + public static ulong CpuUInt64(ulong bigEndian) + => BitConverter.IsLittleEndian ? BinaryPrimitives.ReverseEndianness(bigEndian) : bigEndian; + + public const int MaxRawBytesInt32 = 11, // "-2147483648" + MaxRawBytesInt64 = 20, // "-9223372036854775808", + MaxProtocolBytesIntegerInt32 = MaxRawBytesInt32 + 3, // ?X10X\r\n where ? could be $, *, etc - usually a length prefix + MaxProtocolBytesBulkStringIntegerInt32 = MaxRawBytesInt32 + 7, // $NN\r\nX11X\r\n for NN (length) 1-11 + MaxProtocolBytesBulkStringIntegerInt64 = MaxRawBytesInt64 + 7, // $NN\r\nX20X\r\n for NN (length) 1-20 + MaxRawBytesNumber = 20, // note G17 format, allow 20 for payload + MaxProtocolBytesBytesNumber = MaxRawBytesNumber + 7; // $NN\r\nX...X\r\n for NN (length) 1-20 +} diff --git a/src/RESPite/Internal/RespOperationExtensions.cs b/src/RESPite/Internal/RespOperationExtensions.cs new file mode 100644 index 000000000..78ecd6d53 --- /dev/null +++ b/src/RESPite/Internal/RespOperationExtensions.cs @@ -0,0 +1,57 @@ +using System.Buffers; +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace RESPite.Internal; + +internal static class RespOperationExtensions +{ +#if PREVIEW_LANGVER + extension(in RespOperation operation) + { + // since this is valid... + public ref readonly RespOperation Self => ref operation; + + // so is this (the types are layout-identical) + public ref readonly RespOperation Untyped => ref Unsafe.As, RespOperation>( + ref Unsafe.AsRef(in operation)); + } +#endif + + // if we're recycling a buffer, we need to consider it trashable by other threads; for + // debug purposes, force this by overwriting with *****, aka the meaning of life + [Conditional("DEBUG")] + internal static void DebugScramble(this Span value) + => value.Fill(42); + + [Conditional("DEBUG")] + internal static void DebugScramble(this Memory value) + => value.Span.Fill(42); + + [Conditional("DEBUG")] + internal static void DebugScramble(this ReadOnlyMemory value) + => MemoryMarshal.AsMemory(value).Span.Fill(42); + + [Conditional("DEBUG")] + internal static void DebugScramble(this ReadOnlySequence value) + { + if (value.IsSingleSegment) + { + value.First.DebugScramble(); + } + else + { + foreach (var segment in value) + { + segment.DebugScramble(); + } + } + } + + [Conditional("DEBUG")] + internal static void DebugScramble(this byte[]? value) + { + if (value is not null) + value.AsSpan().Fill(42); + } +} diff --git a/src/RESPite/Internal/SynchronizedBlockBufferSerializer.cs b/src/RESPite/Internal/SynchronizedBlockBufferSerializer.cs new file mode 100644 index 000000000..4f00e5194 --- /dev/null +++ b/src/RESPite/Internal/SynchronizedBlockBufferSerializer.cs @@ -0,0 +1,122 @@ +using System.Buffers; + +namespace RESPite.Internal; + +internal partial class BlockBufferSerializer +{ + internal static BlockBufferSerializer Create(bool retainChain = false) => + new SynchronizedBlockBufferSerializer(retainChain); + + /// + /// Used for things like . + /// + private sealed class SynchronizedBlockBufferSerializer(bool retainChain) : BlockBufferSerializer + { + private bool _discardDuringClear; + + private protected override BlockBuffer? Buffer { get; set; } // simple per-instance auto-prop + + /* + // use lock-based synchronization + public override ReadOnlyMemory Serialize( + RespCommandMap? commandMap, + ReadOnlySpan command, + in TRequest request, + IRespFormatter formatter) + { + bool haveLock = false; + try // note that "lock" unrolls to something very similar; we're not adding anything unusual here + { + // in reality, we *expect* people to not attempt to use batches concurrently, *and* + // we expect serialization to be very fast, but: out of an abundance of caution, + // add a timeout - just to avoid surprises (since people can write their own formatters) + Monitor.TryEnter(this, LockTimeout, ref haveLock); + if (!haveLock) ThrowTimeout(); + return base.Serialize(commandMap, command, in request, formatter); + } + finally + { + if (haveLock) Monitor.Exit(this); + } + + static void ThrowTimeout() => throw new TimeoutException( + "It took a long time to get access to the serialization-buffer. This is very odd - please " + + "ask on GitHub, but *as a guess*, you have a custom RESP formatter that is really slow *and* " + + "you are using concurrent access to a RESP batch / transaction."); + } + */ + + private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5); + + private Segment? _head, _tail; + + protected override bool ClaimSegment(ReadOnlyMemory segment) + { + if (retainChain & !_discardDuringClear) + { + if (_head is null) + { + _head = _tail = new Segment(segment); + } + else + { + _tail = new Segment(segment, _tail); + } + + // note we don't need to increment the ref-count; because of this "true" + return true; + } + + return false; + } + + internal override ReadOnlySequence Flush() + { + if (_head is null) + { + // at worst, single-segment - we can skip the alloc + return new(BlockBuffer.RetainCurrent(this)); + } + + // otherwise, flush everything *keeping the chain* + ClearWithDiscard(discard: false); + ReadOnlySequence seq = new(_head, 0, _tail!, _tail!.Length); + _head = _tail = null; + return seq; + } + + public override void Clear() + { + ClearWithDiscard(discard: true); + _head = _tail = null; + } + + private void ClearWithDiscard(bool discard) + { + try + { + _discardDuringClear = discard; + base.Clear(); + } + finally + { + _discardDuringClear = false; + } + } + + private sealed class Segment : ReadOnlySequenceSegment + { + public Segment(ReadOnlyMemory memory, Segment? previous = null) + { + Memory = memory; + if (previous is not null) + { + previous.Next = this; + RunningIndex = previous.RunningIndex + previous.Length; + } + } + + public int Length => Memory.Length; + } + } +} diff --git a/src/RESPite/Internal/ThreadLocalBlockBufferSerializer.cs b/src/RESPite/Internal/ThreadLocalBlockBufferSerializer.cs new file mode 100644 index 000000000..1c1895ff4 --- /dev/null +++ b/src/RESPite/Internal/ThreadLocalBlockBufferSerializer.cs @@ -0,0 +1,21 @@ +namespace RESPite.Internal; + +internal partial class BlockBufferSerializer +{ + internal static BlockBufferSerializer Shared => ThreadLocalBlockBufferSerializer.Instance; + private sealed class ThreadLocalBlockBufferSerializer : BlockBufferSerializer + { + private ThreadLocalBlockBufferSerializer() { } + public static readonly ThreadLocalBlockBufferSerializer Instance = new(); + + [ThreadStatic] + // side-step concurrency using per-thread semantics + private static BlockBuffer? _perTreadBuffer; + + private protected override BlockBuffer? Buffer + { + get => _perTreadBuffer; + set => _perTreadBuffer = value; + } + } +} diff --git a/src/RESPite/Messages/RespAttributeReader.cs b/src/RESPite/Messages/RespAttributeReader.cs new file mode 100644 index 000000000..9d61802c0 --- /dev/null +++ b/src/RESPite/Messages/RespAttributeReader.cs @@ -0,0 +1,71 @@ +using System.Diagnostics.CodeAnalysis; + +namespace RESPite.Messages; + +/// +/// Allows attribute data to be parsed conveniently. +/// +/// The type of data represented by this reader. +[Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)] +public abstract class RespAttributeReader +{ + /// + /// Parse a group of attributes. + /// + public virtual void Read(ref RespReader reader, ref T value) + { + reader.Demand(RespPrefix.Attribute); + _ = ReadKeyValuePairs(ref reader, ref value); + } + + /// + /// Parse an aggregate as a set of key/value pairs. + /// + /// The number of pairs successfully processed. + protected virtual int ReadKeyValuePairs(ref RespReader reader, ref T value) + { + var iterator = reader.AggregateChildren(); + + byte[] pooledBuffer = []; + Span localBuffer = stackalloc byte[128]; + int count = 0; + while (iterator.MoveNext()) + { + if (iterator.Value.IsScalar) + { + var key = iterator.Value.Buffer(ref pooledBuffer, localBuffer); + + if (iterator.MoveNext()) + { + if (ReadKeyValuePair(key, ref iterator.Value, ref value)) + { + count++; + } + } + else + { + break; // no matching value for this key + } + } + else + { + if (iterator.MoveNext()) + { + // we won't try to handle aggregate keys; skip the value + } + else + { + break; // no matching value for this key + } + } + } + iterator.MovePast(out reader); + return count; + } + + /// + /// Parse an individual key/value pair. + /// + /// True if the pair was successfully processed. + public virtual bool ReadKeyValuePair(scoped ReadOnlySpan key, ref RespReader reader, ref T value) => false; +} diff --git a/src/RESPite/Messages/RespFrameScanner.cs b/src/RESPite/Messages/RespFrameScanner.cs new file mode 100644 index 000000000..a8d88dc5e --- /dev/null +++ b/src/RESPite/Messages/RespFrameScanner.cs @@ -0,0 +1,203 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using static RESPite.Internal.RespConstants; +namespace RESPite.Messages; + +/// +/// Scans RESP frames. +/// . +[Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)] +public sealed class RespFrameScanner // : IFrameSacanner, IFrameValidator +{ + /// + /// Gets a frame scanner for RESP2 request/response connections, or RESP3 connections. + /// + public static RespFrameScanner Default { get; } = new(false); + + /// + /// Gets a frame scanner that identifies RESP2 pub/sub messages. + /// + public static RespFrameScanner Subscription { get; } = new(true); + private RespFrameScanner(bool pubsub) => _pubsub = pubsub; + private readonly bool _pubsub; + + private static readonly uint FastNull = UnsafeCpuUInt32("_\r\n\0"u8), + SingleCharScalarMask = CpuUInt32(0xFF00FFFF), + SingleDigitInteger = UnsafeCpuUInt32(":\0\r\n"u8), + EitherBoolean = UnsafeCpuUInt32("#\0\r\n"u8), + FirstThree = CpuUInt32(0xFFFFFF00); + private static readonly ulong OK = UnsafeCpuUInt64("+OK\r\n\0\0\0"u8), + PONG = UnsafeCpuUInt64("+PONG\r\n\0"u8), + DoubleCharScalarMask = CpuUInt64(0xFF0000FFFF000000), + DoubleDigitInteger = UnsafeCpuUInt64(":\0\0\r\n"u8), + FirstFive = CpuUInt64(0xFFFFFFFFFF000000), + FirstSeven = CpuUInt64(0xFFFFFFFFFFFFFF00); + + private const OperationStatus UseReader = (OperationStatus)(-1); + private static OperationStatus TryFastRead(ReadOnlySpan data, ref RespScanState info) + { + // use silly math to detect the most common short patterns without needing + // to access a reader, or use indexof etc; handles: + // +OK\r\n + // +PONG\r\n + // :N\r\n for any single-digit N (integer) + // :NN\r\n for any double-digit N (integer) + // #N\r\n for any single-digit N (boolean) + // _\r\n (null) + uint hi, lo; + switch (data.Length) + { + case 0: + case 1: + case 2: + return OperationStatus.NeedMoreData; + case 3: + // assume we're reading as little-endian, so: first byte is low + hi = data[0] | ((uint)data[1] << 8) | ((uint)data[2] << 16); + if (!BitConverter.IsLittleEndian) + { + // compensate if necessary (which: it won't be) + hi = BinaryPrimitives.ReverseEndianness(hi); + } + break; + default: + hi = UnsafeCpuUInt32(data); + break; + } + if ((hi & FirstThree) == FastNull) + { + info.SetComplete(3, RespPrefix.Null); + return OperationStatus.Done; + } + + var masked = hi & SingleCharScalarMask; + if (masked == SingleDigitInteger) + { + info.SetComplete(4, RespPrefix.Integer); + return OperationStatus.Done; + } + else if (masked == EitherBoolean) + { + info.SetComplete(4, RespPrefix.Boolean); + return OperationStatus.Done; + } + + switch (data.Length) + { + case 3: + return OperationStatus.NeedMoreData; + case 4: + return UseReader; + case 5: + lo = ((uint)data[4]) << 24; + break; + case 6: + lo = ((uint)UnsafeCpuUInt16(data, 4)) << 16; + break; + case 7: + lo = ((uint)UnsafeCpuUInt16(data, 4)) << 16 | ((uint)UnsafeCpuByte(data, 6)) << 8; + break; + default: + lo = UnsafeCpuUInt32(data, 4); + break; + } + var u64 = BitConverter.IsLittleEndian ? ((((ulong)lo) << 32) | hi) : ((((ulong)hi) << 32) | lo); + if (((u64 & FirstFive) == OK) | ((u64 & DoubleCharScalarMask) == DoubleDigitInteger)) + { + info.SetComplete(5, RespPrefix.SimpleString); + return OperationStatus.Done; + } + if ((u64 & FirstSeven) == PONG) + { + info.SetComplete(7, RespPrefix.SimpleString); + return OperationStatus.Done; + } + return UseReader; + } + + /// + /// Attempt to read more data as part of the current frame. + /// + public OperationStatus TryRead(ref RespScanState state, in ReadOnlySequence data) + { + if (!_pubsub & state.TotalBytes == 0 & data.IsSingleSegment) + { +#if NETCOREAPP3_1_OR_GREATER + var status = TryFastRead(data.FirstSpan, ref state); +#else + var status = TryFastRead(data.First.Span, ref state); +#endif + if (status != UseReader) return status; + } + + return TryReadViaReader(ref state, in data); + + static OperationStatus TryReadViaReader(ref RespScanState state, in ReadOnlySequence data) + { + var reader = new RespReader(in data); + var complete = state.TryRead(ref reader, out var consumed); + if (complete) + { + return OperationStatus.Done; + } + return OperationStatus.NeedMoreData; + } + } + + /// + /// Attempt to read more data as part of the current frame. + /// + public OperationStatus TryRead(ref RespScanState state, ReadOnlySpan data) + { + if (!_pubsub & state.TotalBytes == 0) + { +#if NETCOREAPP3_1_OR_GREATER + var status = TryFastRead(data, ref state); +#else + var status = TryFastRead(data, ref state); +#endif + if (status != UseReader) return status; + } + + return TryReadViaReader(ref state, data); + + static OperationStatus TryReadViaReader(ref RespScanState state, ReadOnlySpan data) + { + var reader = new RespReader(data); + var complete = state.TryRead(ref reader, out var consumed); + if (complete) + { + return OperationStatus.Done; + } + return OperationStatus.NeedMoreData; + } + } + + /// + /// Validate that the supplied message is a valid RESP request, specifically: that it contains a single + /// top-level array payload with bulk-string elements, the first of which is non-empty (the command). + /// + public void ValidateRequest(in ReadOnlySequence message) + { + if (message.IsEmpty) Throw("Empty RESP frame"); + RespReader reader = new(in message); + reader.MoveNext(RespPrefix.Array); + reader.DemandNotNull(); + if (reader.IsStreaming) Throw("Streaming is not supported in this context"); + var count = reader.AggregateLength(); + for (int i = 0; i < count; i++) + { + reader.MoveNext(RespPrefix.BulkString); + reader.DemandNotNull(); + if (reader.IsStreaming) Throw("Streaming is not supported in this context"); + + if (i == 0 && reader.ScalarIsEmpty()) Throw("command must be non-empty"); + } + reader.DemandEnd(); + + static void Throw(string message) => throw new InvalidOperationException(message); + } +} diff --git a/src/RESPite/Messages/RespPrefix.cs b/src/RESPite/Messages/RespPrefix.cs new file mode 100644 index 000000000..d58749120 --- /dev/null +++ b/src/RESPite/Messages/RespPrefix.cs @@ -0,0 +1,100 @@ +using System.Diagnostics.CodeAnalysis; + +namespace RESPite.Messages; + +/// +/// RESP protocol prefix. +/// +[Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)] +public enum RespPrefix : byte +{ + /// + /// Invalid. + /// + None = 0, + + /// + /// Simple strings: +OK\r\n. + /// + SimpleString = (byte)'+', + + /// + /// Simple errors: -ERR message\r\n. + /// + SimpleError = (byte)'-', + + /// + /// Integers: :123\r\n. + /// + Integer = (byte)':', + + /// + /// String with support for binary data: $7\r\nmessage\r\n. + /// + BulkString = (byte)'$', + + /// + /// Multiple inner messages: *1\r\n+message\r\n. + /// + Array = (byte)'*', + + /// + /// Null strings/arrays: _\r\n. + /// + Null = (byte)'_', + + /// + /// Boolean values: #T\r\n. + /// + Boolean = (byte)'#', + + /// + /// Floating-point number: ,123.45\r\n. + /// + Double = (byte)',', + + /// + /// Large integer number: (12...89\r\n. + /// + BigInteger = (byte)'(', + + /// + /// Error with support for binary data: !7\r\nmessage\r\n. + /// + BulkError = (byte)'!', + + /// + /// String that should be interpreted verbatim: =11\r\ntxt:message\r\n. + /// + VerbatimString = (byte)'=', + + /// + /// Multiple sub-items that represent a map. + /// + Map = (byte)'%', + + /// + /// Multiple sub-items that represent a set. + /// + Set = (byte)'~', + + /// + /// Out-of band messages. + /// + Push = (byte)'>', + + /// + /// Continuation of streaming scalar values. + /// + StreamContinuation = (byte)';', + + /// + /// End sentinel for streaming aggregate values. + /// + StreamTerminator = (byte)'.', + + /// + /// Metadata about the next element. + /// + Attribute = (byte)'|', +} diff --git a/src/RESPite/Messages/RespReader.AggregateEnumerator.cs b/src/RESPite/Messages/RespReader.AggregateEnumerator.cs new file mode 100644 index 000000000..7b8bf9b50 --- /dev/null +++ b/src/RESPite/Messages/RespReader.AggregateEnumerator.cs @@ -0,0 +1,279 @@ +using System.Collections; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; + +#pragma warning disable IDE0079 // Remove unnecessary suppression +#pragma warning disable CS0282 // There is no defined ordering between fields in multiple declarations of partial struct +#pragma warning restore IDE0079 // Remove unnecessary suppression + +namespace RESPite.Messages; + +public ref partial struct RespReader +{ + /// + /// Reads the sub-elements associated with an aggregate value. For convenience, when + /// using foreach () the reader + /// is advanced into the child element ready for reading, which bypasses attributes. If attributes + /// are required from child elements, the iterator can be advanced manually (not via + /// foreach using an optional attribute-reader in the call. + /// + public readonly AggregateEnumerator AggregateChildren() => new(in this); + + /// + /// Reads the sub-elements associated with an aggregate value. + /// + public ref struct AggregateEnumerator + { + // Note that _reader is the overall reader that can see outside this aggregate, as opposed + // to Current which is the sub-tree of the current element *only* + private RespReader _reader; + private int _remaining; + + /// + /// Create a new enumerator for the specified . + /// + /// The reader containing the data for this operation. + public AggregateEnumerator(scoped in RespReader reader) + { + reader.DemandAggregate(); + _remaining = reader.IsStreaming ? -1 : reader._length; + _reader = reader; + Value = default; + } + + /// + public readonly AggregateEnumerator GetEnumerator() => this; + + /// + [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] +#if DEBUG +#if NET6_0 || NET8_0 + [Experimental("SERDBG")] +#else + [Experimental("SERDBG", Message = $"Prefer {nameof(Value)}")] +#endif +#endif + public RespReader Current => Value; + + /// + /// Gets the current element associated with this reader. + /// + public RespReader Value; // intentionally a field, because of ref-semantics + + /// + /// Move to the next child if possible, and move the child element into the next node. + /// + public bool MoveNext(RespPrefix prefix) + { + bool result = MoveNextRaw(); + if (result) + { + Value.MoveNext(prefix); + } + return result; + } + + /// + /// Move to the next child if possible, and move the child element into the next node. + /// + /// The type of data represented by this reader. + public bool MoveNext(RespPrefix prefix, RespAttributeReader respAttributeReader, ref T attributes) + { + bool result = MoveNextRaw(respAttributeReader, ref attributes); + if (result) + { + Value.MoveNext(prefix); + } + return result; + } + + /// + /// Move to the next child and leave the reader *ahead of* the first element, + /// allowing us to read attribute data. + /// + /// If you are not consuming attribute data, is preferred. + public bool MoveNextRaw() + { + object? attributes = null; + return MoveNextCore(null, ref attributes); + } + + /// + /// Move to the next child and move into the first element (skipping attributes etc), leaving it ready to consume. + /// + public bool MoveNext() + { + object? attributes = null; + if (MoveNextCore(null, ref attributes)) + { + Value.MoveNext(); + return true; + } + return false; + } + + /// + /// Move to the next child (capturing attribute data) and leave the reader *ahead of* the first element, + /// allowing us to also read attribute data of the child. + /// + /// The type of attribute data represented by this reader. + /// If you are not consuming attribute data, is preferred. + public bool MoveNextRaw(RespAttributeReader respAttributeReader, ref T attributes) + => MoveNextCore(respAttributeReader, ref attributes); + + /// > + private bool MoveNextCore(RespAttributeReader? attributeReader, ref T attributes) + { + if (_remaining == 0) + { + Value = default; + return false; + } + + // in order to provide access to attributes etc, we want Current to be positioned + // *before* the next element; for that, we'll take a snapshot before we read + _reader.MovePastCurrent(); + var snapshot = _reader.Clone(); + + if (!(attributeReader is null + ? _reader.TryReadNextSkipAttributes(skipStreamTerminator: false) + : _reader.TryReadNextProcessAttributes(attributeReader, ref attributes, false))) + { + if (_remaining != 0) ThrowEof(); // incomplete aggregate, simple or streaming + _remaining = 0; + Value = default; + return false; + } + + if (_remaining > 0) + { + // non-streaming, decrement + _remaining--; + } + else if (_reader.Prefix == RespPrefix.StreamTerminator) + { + // end of streaming aggregate + _remaining = 0; + Value = default; + return false; + } + + // move past that sub-tree and trim the "snapshot" state, giving + // us a scoped reader that is *just* that sub-tree + _reader.SkipChildren(); + snapshot.TrimToTotal(_reader.BytesConsumed); + + Value = snapshot; + return true; + } + + /// + /// Move to the end of this aggregate and export the state of the . + /// + /// The reader positioned at the end of the data; this is commonly + /// used to update a tree reader, to get to the next data after the aggregate. + public void MovePast(out RespReader reader) + { + while (MoveNextRaw()) { } + reader = _reader; + } + + /// + /// Moves to the next element, and moves into that element (skipping attributes etc), leaving it ready to consume. + /// + public void DemandNext() + { + if (!MoveNext()) ThrowEof(); + } + + public T ReadOne(Projection projection) + { + DemandNext(); + return projection(ref Value); + } + + public void FillAll(scoped Span target, Projection projection) + { + FillAll(target, ref projection, static (ref projection, ref reader) => projection(ref reader)); + } + + public void FillAll(scoped Span target, ref TState state, Projection projection) +#if NET9_0_OR_GREATER + where TState : allows ref struct +#endif + { + for (int i = 0; i < target.Length; i++) + { + DemandNext(); + target[i] = projection(ref state, ref Value); + } + } + + public void FillAll( + scoped Span target, + Projection first, + Projection second, + Func combine) + { + for (int i = 0; i < target.Length; i++) + { + DemandNext(); + + var x = first(ref Value); + + DemandNext(); + + var y = second(ref Value); + target[i] = combine(x, y); + } + } + + public void FillAll( + scoped Span target, + ref TState state, + Projection first, + Projection second, + Func combine) +#if NET9_0_OR_GREATER + where TState : allows ref struct +#endif + { + for (int i = 0; i < target.Length; i++) + { + DemandNext(); + + var x = first(ref state, ref Value); + + DemandNext(); + + var y = second(ref state, ref Value); + target[i] = combine(state, x, y); + } + } + } + + internal void TrimToTotal(long length) => TrimToRemaining(length - BytesConsumed); + + internal void TrimToRemaining(long bytes) + { + if (_prefix != RespPrefix.None || bytes < 0) Throw(); + + var current = CurrentAvailable; + if (bytes <= current) + { + UnsafeTrimCurrentBy(current - (int)bytes); + _remainingTailLength = 0; + return; + } + + bytes -= current; + if (bytes <= _remainingTailLength) + { + _remainingTailLength = bytes; + return; + } + + Throw(); + static void Throw() => throw new ArgumentOutOfRangeException(nameof(bytes)); + } +} diff --git a/src/RESPite/Messages/RespReader.Debug.cs b/src/RESPite/Messages/RespReader.Debug.cs new file mode 100644 index 000000000..71d5a44af --- /dev/null +++ b/src/RESPite/Messages/RespReader.Debug.cs @@ -0,0 +1,59 @@ +using System.Buffers; +using System.Diagnostics; +using System.Text; + +#pragma warning disable IDE0079 // Remove unnecessary suppression +#pragma warning disable CS0282 // There is no defined ordering between fields in multiple declarations of partial struct +#pragma warning restore IDE0079 // Remove unnecessary suppression + +namespace RESPite.Messages; + +[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] +public ref partial struct RespReader +{ + internal bool DebugEquals(in RespReader other) + => _prefix == other._prefix + && _length == other._length + && _flags == other._flags + && _bufferIndex == other._bufferIndex + && _positionBase == other._positionBase + && _remainingTailLength == other._remainingTailLength; + + internal new string ToString() => $"{Prefix} ({_flags}); length {_length}, {TotalAvailable} remaining"; + + internal void DebugReset() + { + _bufferIndex = 0; + _length = 0; + _flags = 0; + _prefix = RespPrefix.None; + } + +#if DEBUG + internal bool VectorizeDisabled { get; set; } +#endif + + private partial ReadOnlySpan ActiveBuffer { get; } + + internal readonly string BufferUtf8() + { + var clone = Clone(); + var active = clone.ActiveBuffer; + var totalLen = checked((int)(active.Length + clone._remainingTailLength)); + var oversized = ArrayPool.Shared.Rent(totalLen); + Span target = oversized.AsSpan(0, totalLen); + + while (!target.IsEmpty) + { + active.CopyTo(target); + target = target.Slice(active.Length); + if (!clone.TryMoveToNextSegment()) break; + active = clone.ActiveBuffer; + } + if (!target.IsEmpty) throw new EndOfStreamException(); + + var s = Encoding.UTF8.GetString(oversized, 0, totalLen); + ArrayPool.Shared.Return(oversized); + return s; + } +} diff --git a/src/RESPite/Messages/RespReader.ScalarEnumerator.cs b/src/RESPite/Messages/RespReader.ScalarEnumerator.cs new file mode 100644 index 000000000..9e8ffbe70 --- /dev/null +++ b/src/RESPite/Messages/RespReader.ScalarEnumerator.cs @@ -0,0 +1,105 @@ +using System.Buffers; +using System.Collections; + +#pragma warning disable IDE0079 // Remove unnecessary suppression +#pragma warning disable CS0282 // There is no defined ordering between fields in multiple declarations of partial struct +#pragma warning restore IDE0079 // Remove unnecessary suppression + +namespace RESPite.Messages; + +public ref partial struct RespReader +{ + /// + /// Gets the chunks associated with a scalar value. + /// + public readonly ScalarEnumerator ScalarChunks() => new(in this); + + /// + /// Allows enumeration of chunks in a scalar value; this includes simple values + /// that span multiple segments, and streaming + /// scalar RESP values. + /// + public ref struct ScalarEnumerator + { + /// + public readonly ScalarEnumerator GetEnumerator() => this; + + private RespReader _reader; + + private ReadOnlySpan _current; + private ReadOnlySequenceSegment? _tail; + private int _offset, _remaining; + + /// + /// Create a new enumerator for the specified . + /// + /// The reader containing the data for this operation. + public ScalarEnumerator(scoped in RespReader reader) + { + reader.DemandScalar(); + _reader = reader; + InitSegment(); + } + + private void InitSegment() + { + _current = _reader.CurrentSpan(); + _tail = _reader._tail; + _offset = CurrentLength = 0; + _remaining = _reader._length; + if (_reader.TotalAvailable < _remaining) ThrowEof(); + } + + /// + public bool MoveNext() + { + while (true) // for each streaming element + { + _offset += CurrentLength; + while (_remaining > 0) // for each span in the current element + { + // look in the active span + var take = Math.Min(_remaining, _current.Length - _offset); + if (take > 0) // more in the current chunk + { + _remaining -= take; + CurrentLength = take; + return true; + } + + // otherwise, we expect more tail data + if (_tail is null) ThrowEof(); + + _current = _tail.Memory.Span; + _offset = 0; + _tail = _tail.Next; + } + + if (!_reader.MoveNextStreamingScalar()) break; + InitSegment(); + } + + CurrentLength = 0; + return false; + } + + /// + public readonly ReadOnlySpan Current => _current.Slice(_offset, CurrentLength); + + /// + /// Gets the or . + /// + public int CurrentLength { readonly get; private set; } + + /// + /// Move to the end of this aggregate and export the state of the . + /// + /// The reader positioned at the end of the data; this is commonly + /// used to update a tree reader, to get to the next data after the aggregate. + public void MovePast(out RespReader reader) + { + while (MoveNext()) { } + reader = _reader; + } + } +} diff --git a/src/RESPite/Messages/RespReader.Span.cs b/src/RESPite/Messages/RespReader.Span.cs new file mode 100644 index 000000000..ea3f0f536 --- /dev/null +++ b/src/RESPite/Messages/RespReader.Span.cs @@ -0,0 +1,86 @@ +#define USE_UNSAFE_SPAN + +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +#pragma warning disable IDE0079 // Remove unnecessary suppression +#pragma warning disable CS0282 // There is no defined ordering between fields in multiple declarations of partial struct +#pragma warning restore IDE0079 // Remove unnecessary suppression + +namespace RESPite.Messages; + +/* + How we actually implement the underlying buffer depends on the capabilities of the runtime. + */ + +#if NET7_0_OR_GREATER && USE_UNSAFE_SPAN + +public ref partial struct RespReader +{ + // intent: avoid lots of slicing by dealing with everything manually, and accepting the "don't get it wrong" rule + private ref byte _bufferRoot; + private int _bufferLength; + + private partial void UnsafeTrimCurrentBy(int count) + { + Debug.Assert(count >= 0 && count <= _bufferLength, "Unsafe trim length"); + _bufferLength -= count; + } + + private readonly partial ref byte UnsafeCurrent + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => ref Unsafe.Add(ref _bufferRoot, _bufferIndex); + } + + private readonly partial int CurrentLength + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _bufferLength; + } + + private readonly partial ReadOnlySpan CurrentSpan() => MemoryMarshal.CreateReadOnlySpan( + ref UnsafeCurrent, CurrentAvailable); + + private readonly partial ReadOnlySpan UnsafePastPrefix() => MemoryMarshal.CreateReadOnlySpan( + ref Unsafe.Add(ref _bufferRoot, _bufferIndex + 1), + _bufferLength - (_bufferIndex + 1)); + + private partial void SetCurrent(ReadOnlySpan value) + { + _bufferRoot = ref MemoryMarshal.GetReference(value); + _bufferLength = value.Length; + } + private partial ReadOnlySpan ActiveBuffer => MemoryMarshal.CreateReadOnlySpan(ref _bufferRoot, _bufferLength); +} +#else +public ref partial struct RespReader // much more conservative - uses slices etc +{ + private ReadOnlySpan _buffer; + + private partial void UnsafeTrimCurrentBy(int count) + { + _buffer = _buffer.Slice(0, _buffer.Length - count); + } + + private readonly partial ref byte UnsafeCurrent + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => ref Unsafe.AsRef(in _buffer[_bufferIndex]); // hack around CS8333 + } + + private readonly partial int CurrentLength + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _buffer.Length; + } + + private readonly partial ReadOnlySpan UnsafePastPrefix() => _buffer.Slice(_bufferIndex + 1); + + private readonly partial ReadOnlySpan CurrentSpan() => _buffer.Slice(_bufferIndex); + + private partial void SetCurrent(ReadOnlySpan value) => _buffer = value; + private partial ReadOnlySpan ActiveBuffer => _buffer; +} +#endif diff --git a/src/RESPite/Messages/RespReader.Utils.cs b/src/RESPite/Messages/RespReader.Utils.cs new file mode 100644 index 000000000..9aca671fb --- /dev/null +++ b/src/RESPite/Messages/RespReader.Utils.cs @@ -0,0 +1,341 @@ +using System.Buffers.Text; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using RESPite.Internal; + +#pragma warning disable IDE0079 // Remove unnecessary suppression +#pragma warning disable CS0282 // There is no defined ordering between fields in multiple declarations of partial struct +#pragma warning restore IDE0079 // Remove unnecessary suppression + +namespace RESPite.Messages; + +public ref partial struct RespReader +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void UnsafeAssertClLf(int offset) => UnsafeAssertClLf(ref UnsafeCurrent, offset); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private readonly void UnsafeAssertClLf(scoped ref byte source, int offset) + { + if (Unsafe.ReadUnaligned(ref Unsafe.Add(ref source, offset)) != RespConstants.CrLfUInt16) + { + ThrowProtocolFailure($"Expected CR/LF ({offset}={(char)Unsafe.Add(ref source, offset)})"); + } + } + + private enum LengthPrefixResult + { + NeedMoreData, + Length, + Null, + Streaming, + } + + /// + /// Asserts that the current element is a scalar type. + /// + public readonly void DemandScalar() + { + if (!IsScalar) Throw(Prefix); + static void Throw(RespPrefix prefix) => throw new InvalidOperationException($"This operation requires a scalar element, got {prefix}"); + } + + /// + /// Asserts that the current element is a scalar type. + /// + public readonly void DemandAggregate() + { + if (!IsAggregate) Throw(Prefix); + static void Throw(RespPrefix prefix) => throw new InvalidOperationException($"This operation requires an aggregate element, got {prefix}"); + } + + private readonly LengthPrefixResult TryReadLengthPrefix(ReadOnlySpan bytes, out int value, out int byteCount) + { + var end = bytes.IndexOf(RespConstants.CrlfBytes); + if (end < 0) + { + byteCount = value = 0; + if (bytes.Length >= RespConstants.MaxRawBytesInt32 + 2) + { + ThrowProtocolFailure("Unterminated or over-length integer"); // should have failed; report failure to prevent infinite loop + } + return LengthPrefixResult.NeedMoreData; + } + byteCount = end + 2; + switch (end) + { + case 0: + ThrowProtocolFailure("Length prefix expected"); + goto case default; // not reached, just satisfying definite assignment + case 1 when bytes[0] == (byte)'?': + value = 0; + return LengthPrefixResult.Streaming; + default: + if (end > RespConstants.MaxRawBytesInt32 || !(Utf8Parser.TryParse(bytes, out value, out var consumed) && consumed == end)) + { + ThrowProtocolFailure("Unable to parse integer"); + value = 0; + } + if (value < 0) + { + if (value == -1) + { + value = 0; + return LengthPrefixResult.Null; + } + ThrowProtocolFailure("Invalid negative length prefix"); + } + return LengthPrefixResult.Length; + } + } + + /// + /// Create an isolated copy of this reader, which can be advanced independently. + /// + public readonly RespReader Clone() => this; // useful for performing streaming operations without moving the primary + + [MethodImpl(MethodImplOptions.NoInlining), DoesNotReturn] + private readonly void ThrowProtocolFailure(string message) + => throw new InvalidOperationException($"RESP protocol failure around offset {_positionBase}-{BytesConsumed}: {message}"); // protocol exception? + + [MethodImpl(MethodImplOptions.NoInlining), DoesNotReturn] + internal static void ThrowEof() => throw new EndOfStreamException(); + + [MethodImpl(MethodImplOptions.NoInlining), DoesNotReturn] + private static void ThrowFormatException() => throw new FormatException(); + + private int RawTryReadByte() + { + if (_bufferIndex < CurrentLength || TryMoveToNextSegment()) + { + var result = UnsafeCurrent; + _bufferIndex++; + return result; + } + return -1; + } + + private int RawPeekByte() + { + return (CurrentLength < _bufferIndex || TryMoveToNextSegment()) ? UnsafeCurrent : -1; + } + + private bool RawAssertCrLf() + { + if (CurrentAvailable >= 2) + { + UnsafeAssertClLf(0); + _bufferIndex += 2; + return true; + } + else + { + int next = RawTryReadByte(); + if (next < 0) return false; + if (next == '\r') + { + next = RawTryReadByte(); + if (next < 0) return false; + if (next == '\n') return true; + } + ThrowProtocolFailure("Expected CR/LF"); + return false; + } + } + + private LengthPrefixResult RawTryReadLengthPrefix() + { + _length = 0; + if (!RawTryFindCrLf(out int end)) + { + if (TotalAvailable >= RespConstants.MaxRawBytesInt32 + 2) + { + ThrowProtocolFailure("Unterminated or over-length integer"); // should have failed; report failure to prevent infinite loop + } + return LengthPrefixResult.NeedMoreData; + } + + switch (end) + { + case 0: + ThrowProtocolFailure("Length prefix expected"); + goto case default; // not reached, just satisfying definite assignment + case 1: + var b = (byte)RawTryReadByte(); + RawAssertCrLf(); + if (b == '?') + { + return LengthPrefixResult.Streaming; + } + else + { + _length = ParseSingleDigit(b); + return LengthPrefixResult.Length; + } + default: + if (end > RespConstants.MaxRawBytesInt32) + { + ThrowProtocolFailure("Unable to parse integer"); + } + Span bytes = stackalloc byte[end]; + RawFillBytes(bytes); + RawAssertCrLf(); + if (!(Utf8Parser.TryParse(bytes, out _length, out var consumed) && consumed == end)) + { + ThrowProtocolFailure("Unable to parse integer"); + } + + if (_length < 0) + { + if (_length == -1) + { + _length = 0; + return LengthPrefixResult.Null; + } + ThrowProtocolFailure("Invalid negative length prefix"); + } + + return LengthPrefixResult.Length; + } + } + + private void RawFillBytes(scoped Span target) + { + do + { + var current = CurrentSpan(); + if (current.Length >= target.Length) + { + // more than enough, need to trim + current.Slice(0, target.Length).CopyTo(target); + _bufferIndex += target.Length; + return; // we're done + } + else + { + // take what we can + current.CopyTo(target); + target = target.Slice(current.Length); + // we could move _bufferIndex here, but we're about to trash that in TryMoveToNextSegment + } + } + while (TryMoveToNextSegment()); + ThrowEof(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int ParseSingleDigit(byte value) + { + return value switch + { + (byte)'0' or (byte)'1' or (byte)'2' or (byte)'3' or (byte)'4' or (byte)'5' or (byte)'6' or (byte)'7' or (byte)'8' or (byte)'9' => value - (byte)'0', + _ => Invalid(value), + }; + + [MethodImpl(MethodImplOptions.NoInlining), DoesNotReturn] + static int Invalid(byte value) => throw new FormatException($"Unable to parse integer: '{(char)value}'"); + } + + private readonly bool RawTryAssertInlineScalarPayloadCrLf() + { + Debug.Assert(IsInlineScalar, "should be inline scalar"); + + var reader = Clone(); + var len = reader._length; + if (len == 0) return reader.RawAssertCrLf(); + + do + { + var current = reader.CurrentSpan(); + if (current.Length >= len) + { + reader._bufferIndex += len; + return reader.RawAssertCrLf(); // we're done + } + else + { + // take what we can + len -= current.Length; + // we could move _bufferIndex here, but we're about to trash that in TryMoveToNextSegment + } + } + while (reader.TryMoveToNextSegment()); + return false; // EOF + } + + private readonly bool RawTryFindCrLf(out int length) + { + length = 0; + RespReader reader = Clone(); + do + { + var span = reader.CurrentSpan(); + var index = span.IndexOf((byte)'\r'); + if (index >= 0) + { + checked + { + length += index; + } + // move past the CR and assert the LF + reader._bufferIndex += index + 1; + var next = reader.RawTryReadByte(); + if (next < 0) break; // we don't know + if (next != '\n') ThrowProtocolFailure("CR/LF expected"); + + return true; + } + checked + { + length += span.Length; + } + } + while (reader.TryMoveToNextSegment()); + length = 0; + return false; + } + + private string GetDebuggerDisplay() + { + return ToString(); + } + + internal readonly int GetInitialScanCount(out ushort streamingAggregateDepth) + { + // this is *similar* to GetDelta, but: without any discount for attributes + switch (_flags & (RespFlags.IsAggregate | RespFlags.IsStreaming)) + { + case RespFlags.IsAggregate: + streamingAggregateDepth = 0; + return _length - 1; + case RespFlags.IsAggregate | RespFlags.IsStreaming: + streamingAggregateDepth = 1; + return 0; + default: + streamingAggregateDepth = 0; + return -1; + } + } + + /// + /// Get the raw RESP payload. + /// + public readonly byte[] Serialize() + { + var reader = Clone(); + int remaining = checked((int)reader.TotalAvailable); + var arr = new byte[remaining]; + Span target = arr; + while (remaining > 0) + { + var span = reader.CurrentSpan(); + span.CopyTo(arr); + remaining -= span.Length; + target = target.Slice(span.Length); + if (!reader.TryMoveToNextSegment()) break; + } + if (remaining != 0 | !target.IsEmpty) ThrowEof(); + return arr; + } +} diff --git a/src/RESPite/Messages/RespReader.cs b/src/RESPite/Messages/RespReader.cs new file mode 100644 index 000000000..6bc42dd14 --- /dev/null +++ b/src/RESPite/Messages/RespReader.cs @@ -0,0 +1,2037 @@ +using System.Buffers; +using System.Buffers.Text; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text; +using RESPite.Internal; + +#if NETCOREAPP3_0_OR_GREATER +using System.Runtime.Intrinsics; +using System.Runtime.Intrinsics.X86; +#endif + +#pragma warning disable IDE0079 // Remove unnecessary suppression +#pragma warning disable CS0282 // There is no defined ordering between fields in multiple declarations of partial struct +#pragma warning restore IDE0079 // Remove unnecessary suppression + +namespace RESPite.Messages; + +/// +/// Provides low level RESP parsing functionality. +/// +[Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)] +public ref partial struct RespReader +{ + [Flags] + private enum RespFlags : byte + { + None = 0, + IsScalar = 1 << 0, // simple strings, bulk strings, etc + IsAggregate = 1 << 1, // arrays, maps, sets, etc + IsNull = 1 << 2, // explicit null RESP types, or bulk-strings/aggregates with length -1 + IsInlineScalar = 1 << 3, // a non-null scalar, i.e. with payload+CrLf + IsAttribute = 1 << 4, // is metadata for following elements + IsStreaming = 1 << 5, // unknown length + IsError = 1 << 6, // an explicit error reported inside the protocol + } + + // relates to the element we're currently reading + private RespFlags _flags; + private RespPrefix _prefix; + + private int _length; // for null: 0; for scalars: the length of the payload; for aggregates: the child count + + // the current buffer that we're observing + private int _bufferIndex; // after TryRead, this should be positioned immediately before the actual data + + // the position in a multi-segment payload + private long _positionBase; // total data we've already moved past in *previous* buffers + private ReadOnlySequenceSegment? _tail; // the next tail node + private long _remainingTailLength; // how much more can we consume from the tail? + + public long ProtocolBytesRemaining => TotalAvailable; + + private readonly int CurrentAvailable => CurrentLength - _bufferIndex; + + private readonly long TotalAvailable => CurrentAvailable + _remainingTailLength; + private partial void UnsafeTrimCurrentBy(int count); + private readonly partial ref byte UnsafeCurrent { get; } + private readonly partial int CurrentLength { get; } + private partial void SetCurrent(ReadOnlySpan value); + private RespPrefix UnsafePeekPrefix() => (RespPrefix)UnsafeCurrent; + private readonly partial ReadOnlySpan UnsafePastPrefix(); + private readonly partial ReadOnlySpan CurrentSpan(); + + /// + /// Get the scalar value as a single-segment span. + /// + /// True if this is a non-streaming scalar element that covers a single span only, otherwise False. + /// If a scalar reports False, can be used to iterate the entire payload. + /// When True, the contents of the scalar value. + public readonly bool TryGetSpan(out ReadOnlySpan value) + { + if (IsInlineScalar && CurrentAvailable >= _length) + { + value = CurrentSpan().Slice(0, _length); + return true; + } + + value = default; + return IsNullScalar; + } + + /// + /// Returns the position after the end of the current element. + /// + public readonly long BytesConsumed => _positionBase + _bufferIndex + TrailingLength; + + /// + /// Body length of scalar values, plus any terminating sentinels. + /// + private readonly int TrailingLength => (_flags & RespFlags.IsInlineScalar) == 0 ? 0 : (_length + 2); + + /// + /// Gets the RESP kind of the current element. + /// + public readonly RespPrefix Prefix => _prefix; + + /// + /// The payload length of this scalar element (includes combined length for streaming scalars). + /// + public readonly int ScalarLength() => + IsInlineScalar ? _length : IsNullScalar ? 0 : checked((int)ScalarLengthSlow()); + + /// + /// Indicates whether this scalar value is zero-length. + /// + public readonly bool ScalarIsEmpty() => + IsInlineScalar ? _length == 0 : (IsNullScalar || !ScalarChunks().MoveNext()); + + /// + /// Indicates whether this aggregate value is zero-length. + /// + public readonly bool AggregateIsEmpty() => AggregateLengthIs(0); + + /// + /// The payload length of this scalar element (includes combined length for streaming scalars). + /// + public readonly long ScalarLongLength() => IsInlineScalar ? _length : IsNullScalar ? 0 : ScalarLengthSlow(); + + /// + /// Indicates whether the payload length of this scalar element is exactly the specified value. + /// + public readonly bool ScalarLengthIs(int count) + => IsInlineScalar ? _length == count : (IsNullScalar ? count == 0 : ScalarLengthIsSlow(count)); + + private readonly long ScalarLengthSlow() + { + DemandScalar(); + long length = 0; + var iterator = ScalarChunks(); + while (iterator.MoveNext()) + { + length += iterator.CurrentLength; + } + + return length; + } + + private readonly bool ScalarLengthIsSlow(int expected) + { + DemandScalar(); + int length = 0; + var iterator = ScalarChunks(); + while (length <= expected && iterator.MoveNext()) // short-circuit if we've read enough to know + { + length += iterator.CurrentLength; + } + + return length == expected; + } + + /// + /// The number of child elements associated with an aggregate. + /// + /// For + /// and aggregates, this is twice the value reported in the RESP protocol, + /// i.e. a map of the form %2\r\n... will report 4 as the length. + /// Note that if the data could be streaming (), it may be preferable to use + /// the API, using the API to update the outer reader. + public readonly int AggregateLength() => + (_flags & (RespFlags.IsAggregate | RespFlags.IsStreaming)) == RespFlags.IsAggregate + ? _length : AggregateLengthSlow(); + + /// + /// Indicates whether the number of child elements associated with an aggregate is exactly the specified value. + /// + /// For + /// and aggregates, this is twice the value reported in the RESP protocol, + /// i.e. a map of the form %2\r\n... will report 4 as the length. + public readonly bool AggregateLengthIs(int count) + => (_flags & (RespFlags.IsAggregate | RespFlags.IsStreaming)) == RespFlags.IsAggregate + ? _length == count : AggregateLengthIsSlow(count); + + public delegate T Projection(ref RespReader value); + + public delegate TResult Projection(ref TState state, ref RespReader value) +#if NET9_0_OR_GREATER + where TState : allows ref struct +#endif + ; + + public void FillAll(scoped Span target, Projection projection) + { + DemandNotNull(); + AggregateChildren().FillAll(target, projection); + } + + public void FillAll(scoped Span target, ref TState state, Projection projection) + { + DemandNotNull(); + AggregateChildren().FillAll(target, ref state, projection); + } + + private readonly int AggregateLengthSlow() + { + switch (_flags & (RespFlags.IsAggregate | RespFlags.IsStreaming)) + { + case RespFlags.IsAggregate: + return _length; + case RespFlags.IsAggregate | RespFlags.IsStreaming: + break; + default: + DemandAggregate(); // we expect this to throw + break; + } + + int count = 0; + var reader = Clone(); + while (true) + { + if (!reader.TryReadNextSkipAttributes(skipStreamTerminator: false)) ThrowEof(); + if (reader.Prefix == RespPrefix.StreamTerminator) + { + return count; + } + + reader.SkipChildren(); + count++; + } + } + + private readonly bool AggregateLengthIsSlow(int expected) + { + switch (_flags & (RespFlags.IsAggregate | RespFlags.IsStreaming)) + { + case RespFlags.IsAggregate: + return _length == expected; + case RespFlags.IsAggregate | RespFlags.IsStreaming: + break; + default: + DemandAggregate(); // we expect this to throw + break; + } + + int count = 0; + var reader = Clone(); + while (count <= expected) // short-circuit if we've read enough to know + { + if (!reader.TryReadNextSkipAttributes(skipStreamTerminator: false)) ThrowEof(); + if (reader.Prefix == RespPrefix.StreamTerminator) + { + break; + } + + reader.SkipChildren(); + count++; + } + return count == expected; + } + + /// + /// Indicates whether this is a scalar value, i.e. with a potential payload body. + /// + public readonly bool IsScalar => (_flags & RespFlags.IsScalar) != 0; + + internal readonly bool IsInlineScalar => (_flags & RespFlags.IsInlineScalar) != 0; + + internal readonly bool IsNullScalar => + (_flags & (RespFlags.IsScalar | RespFlags.IsNull)) == (RespFlags.IsScalar | RespFlags.IsNull); + + /// + /// Indicates whether this is an aggregate value, i.e. represents a collection of sub-values. + /// + public readonly bool IsAggregate => (_flags & RespFlags.IsAggregate) != 0; + + internal readonly bool IsNonNullAggregate + => (_flags & (RespFlags.IsAggregate | RespFlags.IsNull)) == RespFlags.IsAggregate; + + /// + /// Indicates whether this is a null value; this could be an explicit , + /// or a scalar or aggregate a negative reported length. + /// + public readonly bool IsNull => (_flags & RespFlags.IsNull) != 0; + + /// + /// Indicates whether this is an attribute value, i.e. metadata relating to later element data. + /// + public readonly bool IsAttribute => (_flags & RespFlags.IsAttribute) != 0; + + /// + /// Indicates whether this represents streaming content, where the or is not known in advance. + /// + public readonly bool IsStreaming => (_flags & RespFlags.IsStreaming) != 0; + + /// + /// Equivalent to both and . + /// + internal readonly bool IsStreamingScalar => (_flags & (RespFlags.IsScalar | RespFlags.IsStreaming)) == + (RespFlags.IsScalar | RespFlags.IsStreaming); + + /// + /// Indicates errors reported inside the protocol. + /// + public readonly bool IsError => (_flags & RespFlags.IsError) != 0; + + /// + /// Gets the effective change (in terms of how many RESP nodes we expect to see) from consuming this element. + /// For simple scalars, this is -1 because we have one less node to read; for simple aggregates, this is + /// AggregateLength-1 because we will have consumed one element, but now need to read the additional + /// child elements. Attributes report 0, since they supplement data + /// we still need to consume. The final terminator for streaming data reports a delta of -1, otherwise: 0. + /// + /// This does not account for being nested inside a streaming aggregate; the caller must deal with that manually. + internal int Delta() => + (_flags & (RespFlags.IsScalar | RespFlags.IsAggregate | RespFlags.IsStreaming | RespFlags.IsAttribute)) switch + { + RespFlags.IsScalar | RespFlags.IsAggregate=> -1, // null has this + RespFlags.IsScalar => -1, + RespFlags.IsAggregate => _length - 1, + RespFlags.IsAggregate | RespFlags.IsAttribute => _length, + _ => 0, + }; + + /// + /// Assert that this is the final element in the current payload. + /// + /// If additional elements are available. + public void DemandEnd() + { +#pragma warning disable CS0618 // avoid TryReadNext unless you know what you're doing + while (IsStreamingScalar) + { + if (!TryReadNext()) ThrowEof(); + } + + if (TryReadNext()) + { + Throw(Prefix); + } +#pragma warning restore CS0618 + + static void Throw(RespPrefix prefix) => + throw new InvalidOperationException($"Expected end of payload, but found {prefix}"); + } + + private bool TryReadNextSkipAttributes(bool skipStreamTerminator) + { +#pragma warning disable CS0618 // avoid TryReadNext unless you know what you're doing + while (TryReadNext()) + { + if (IsAttribute) + { + SkipChildren(); + } + else if (skipStreamTerminator & Prefix is RespPrefix.StreamTerminator) + { + // skip terminator + } + else + { + return true; + } + } +#pragma warning restore CS0618 + return false; + } + + private bool TryReadNextProcessAttributes(RespAttributeReader respAttributeReader, ref T attributes, bool skipStreamTerminator) + { +#pragma warning disable CS0618 // avoid TryReadNext unless you know what you're doing + while (TryReadNext()) +#pragma warning restore CS0618 + { + if (IsAttribute) + { + respAttributeReader.Read(ref this, ref attributes); + } + else if (skipStreamTerminator & Prefix is RespPrefix.StreamTerminator) + { + // skip terminator + } + else + { + return true; + } + } + + return false; + } + + /// + /// Move to the next content element; this skips attribute metadata, checking for RESP error messages by default. + /// + /// If the data is exhausted before a streaming scalar is exhausted. + /// If the data contains an explicit error element. + public bool TryMoveNext() + { + while (IsStreamingScalar) // close out the current streaming scalar + { + if (!TryReadNextSkipAttributes(false)) ThrowEof(); + } + + if (TryReadNextSkipAttributes(true)) + { + if (IsError) ThrowError(); + return true; + } + + return false; + } + + /// + /// Move to the next content element; this skips attribute metadata, checking for RESP error messages by default. + /// + /// Whether to check and throw for error messages. + /// If the data is exhausted before a streaming scalar is exhausted. + /// If the data contains an explicit error element. + public bool TryMoveNext(bool checkError) + { + while (IsStreamingScalar) // close out the current streaming scalar + { + if (!TryReadNextSkipAttributes(false)) ThrowEof(); + } + + if (TryReadNextSkipAttributes(true)) + { + if (checkError && IsError) ThrowError(); + return true; + } + + return false; + } + + /// + /// Move to the next content element; this skips attribute metadata, checking for RESP error messages by default. + /// + /// Parser for attribute data preceding the data. + /// The state for attributes encountered. + /// If the data is exhausted before a streaming scalar is exhausted. + /// If the data contains an explicit error element. + /// The type of data represented by this reader. + public bool TryMoveNext(RespAttributeReader respAttributeReader, ref T attributes) + { + while (IsStreamingScalar) // close out the current streaming scalar + { + if (!TryReadNextSkipAttributes(false)) ThrowEof(); + } + + if (TryReadNextProcessAttributes(respAttributeReader, ref attributes, true)) + { + if (IsError) ThrowError(); + return true; + } + + return false; + } + + /// + /// Move to the next content element, asserting that it is of the expected type; this skips attribute metadata, checking for RESP error messages by default. + /// + /// The expected data type. + /// If the data is exhausted before a streaming scalar is exhausted. + /// If the data contains an explicit error element. + /// If the data is not of the expected type. + public bool TryMoveNext(RespPrefix prefix) + { + bool result = TryMoveNext(); + if (result) Demand(prefix); + return result; + } + + /// + /// Move to the next content element; this skips attribute metadata, checking for RESP error messages by default. + /// + /// If the data is exhausted before content is found. + /// If the data contains an explicit error element. + public void MoveNext() + { + if (!TryMoveNext()) ThrowEof(); + } + + /// + /// Move to the next content element; this skips attribute metadata, checking for RESP error messages by default. + /// + /// Parser for attribute data preceding the data. + /// The state for attributes encountered. + /// If the data is exhausted before content is found. + /// If the data contains an explicit error element. + /// The type of data represented by this reader. + public void MoveNext(RespAttributeReader respAttributeReader, ref T attributes) + { + if (!TryMoveNext(respAttributeReader, ref attributes)) ThrowEof(); + } + + private bool MoveNextStreamingScalar() + { + if (IsStreamingScalar) + { +#pragma warning disable CS0618 // avoid TryReadNext unless you know what you're doing + while (TryReadNext()) +#pragma warning restore CS0618 + { + if (IsAttribute) + { + SkipChildren(); + } + else + { + if (Prefix != RespPrefix.StreamContinuation) + ThrowProtocolFailure("Streaming continuation expected"); + return _length > 0; + } + } + + ThrowEof(); // we should have found something! + } + + return false; + } + + /// + /// Move to the next content element () and assert that it is a scalar (). + /// + /// If the data is exhausted before content is found. + /// If the data contains an explicit error element. + /// If the data is not a scalar type. + public void MoveNextScalar() + { + MoveNext(); + DemandScalar(); + } + + /// + /// Move to the next content element () and assert that it is an aggregate (). + /// + /// If the data is exhausted before content is found. + /// If the data contains an explicit error element. + /// If the data is not an aggregate type. + public void MoveNextAggregate() + { + MoveNext(); + DemandAggregate(); + } + + /// + /// Move to the next content element () and assert that it of type specified + /// in . + /// + /// The expected data type. + /// Parser for attribute data preceding the data. + /// The state for attributes encountered. + /// If the data is exhausted before content is found. + /// If the data contains an explicit error element. + /// If the data is not of the expected type. + /// The type of data represented by this reader. + public void MoveNext(RespPrefix prefix, RespAttributeReader respAttributeReader, ref T attributes) + { + MoveNext(respAttributeReader, ref attributes); + Demand(prefix); + } + + /// + /// Move to the next content element () and assert that it of type specified + /// in . + /// + /// The expected data type. + /// If the data is exhausted before content is found. + /// If the data contains an explicit error element. + /// If the data is not of the expected type. + public void MoveNext(RespPrefix prefix) + { + MoveNext(); + Demand(prefix); + } + + internal void Demand(RespPrefix prefix) + { + if (Prefix != prefix) Throw(prefix, Prefix); + + static void Throw(RespPrefix expected, RespPrefix actual) => + throw new InvalidOperationException($"Expected {expected} element, but found {actual}."); + } + + private readonly void ThrowError() => throw new RespException(ReadString()!); + + /// + /// Skip all sub elements of the current node; this includes both aggregate children and scalar streaming elements. + /// + public void SkipChildren() + { + // if this is a simple non-streaming scalar, then: there's nothing complex to do; otherwise, re-use the + // frame scanner logic to seek past the noise (this way, we avoid recursion etc) + switch (_flags & (RespFlags.IsScalar | RespFlags.IsAggregate | RespFlags.IsStreaming)) + { + case RespFlags.None: + // no current element + break; + case RespFlags.IsScalar: + // simple scalar + MovePastCurrent(); + break; + default: + // something more complex + RespScanState state = new(in this); + if (!state.TryRead(ref this, out _)) ThrowEof(); + break; + } + } + + /// + /// Reads the current element as a string value. + /// + public readonly string? ReadString() => ReadString(out _); + + /// + /// Reads the current element as a string value. + /// + public readonly string? ReadString(out string prefix) + { + byte[] pooled = []; + try + { + var span = Buffer(ref pooled, stackalloc byte[256]); + prefix = ""; + if (span.IsEmpty) + { + return IsNull ? null : ""; + } + + if (Prefix == RespPrefix.VerbatimString + && span.Length >= 4 && span[3] == ':') + { + // "the first three bytes provide information about the format of the following string, + // which can be txt for plain text, or mkd for markdown. The fourth byte is always :. + // Then the real string follows." + var prefixValue = RespConstants.UnsafeCpuUInt32(span); + if (prefixValue == PrefixTxt) + { + prefix = "txt"; + } + else if (prefixValue == PrefixMkd) + { + prefix = "mkd"; + } + else + { + prefix = RespConstants.UTF8.GetString(span.Slice(0, 3)); + } + + span = span.Slice(4); + } + + return RespConstants.UTF8.GetString(span); + } + finally + { + ArrayPool.Shared.Return(pooled); + } + } + + private static readonly uint + PrefixTxt = RespConstants.UnsafeCpuUInt32("txt:"u8), + PrefixMkd = RespConstants.UnsafeCpuUInt32("mkd:"u8); + + /// + /// Reads the current element as a string value. + /// + public readonly byte[]? ReadByteArray() + { + byte[] pooled = []; + try + { + var span = Buffer(ref pooled, stackalloc byte[256]); + if (span.IsEmpty) + { + return IsNull ? null : []; + } + + return span.ToArray(); + } + finally + { + ArrayPool.Shared.Return(pooled); + } + } + + /// + /// Reads the current element using a general purpose text parser. + /// + /// The type of data being parsed. + internal readonly T ParseBytes(Parser parser) + { + byte[] pooled = []; + var span = Buffer(ref pooled, stackalloc byte[256]); + try + { + return parser(span); + } + finally + { + ArrayPool.Shared.Return(pooled); + } + } + + /// + /// Reads the current element using a general purpose text parser. + /// + /// The type of data being parsed. + /// State required by the parser. + internal readonly T ParseBytes(Parser parser, TState? state) + { + byte[] pooled = []; + var span = Buffer(ref pooled, stackalloc byte[256]); + try + { + return parser(span, default); + } + finally + { + ArrayPool.Shared.Return(pooled); + } + } + + public readonly unsafe bool TryParseScalar( + delegate* managed, out T, bool> parser, out T value) + { + // Fast path: try to get the span directly + return TryGetSpan(out var span) ? parser(span, out value) : TryParseSlow(parser, out value); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private readonly unsafe bool TryParseSlow( + delegate* managed, out T, bool> parser, + out T value) + { + byte[] pooled = []; + try + { + var span = Buffer(ref pooled, stackalloc byte[256]); + return parser(span, out value); + } + finally + { + ArrayPool.Shared.Return(pooled); + } + } + + /// + /// Tries to read the current scalar element using a parser callback. + /// + /// The type of data being parsed. + /// The parser callback. + /// The parsed value if successful. + /// true if parsing succeeded; otherwise, false. +#pragma warning disable RS0016, RS0027 // public API + [Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] +#if DEBUG + [Obsolete("Please prefer the function-pointer API for library-internal use.")] +#endif + public readonly bool TryParseScalar(ScalarParser parser, out T value) +#pragma warning restore RS0016, RS0027 // public API + { + // Fast path: try to get the span directly + return TryGetSpan(out var span) ? parser(span, out value) : TryParseSlow(parser, out value); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private readonly bool TryParseSlow(ScalarParser parser, out T value) + { + byte[] pooled = []; + try + { + var span = Buffer(ref pooled, stackalloc byte[256]); + return parser(span, out value); + } + finally + { + ArrayPool.Shared.Return(pooled); + } + } + + /// + /// Buffers the current scalar value into the provided target span. + /// + /// The target span to buffer data into. + /// + /// A span containing the buffered data. If the scalar data fits entirely within , + /// returns a slice of containing all the data. If the scalar data is larger than + /// , returns filled with the first target.Length bytes + /// of the scalar data (the remaining data is not buffered). + /// + /// + /// This method first attempts to use to avoid copying. If the data is non-contiguous + /// (e.g., streaming scalars or data spanning multiple buffer segments), it will copy data into . + /// When the source data exceeds 's capacity, only the first target.Length bytes + /// are copied and returned. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal readonly ReadOnlySpan Buffer(Span target) + { + if (TryGetSpan(out var simple)) + { + return simple; + } + +#if NET6_0_OR_GREATER + return BufferSlow(ref Unsafe.NullRef(), target, usePool: false); +#else + byte[] pooled = []; + return BufferSlow(ref pooled, target, usePool: false); +#endif + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal readonly ReadOnlySpan Buffer(scoped ref byte[] pooled, Span target = default) + => TryGetSpan(out var simple) ? simple : BufferSlow(ref pooled, target, true); + + [MethodImpl(MethodImplOptions.NoInlining)] + private readonly ReadOnlySpan BufferSlow(scoped ref byte[] pooled, Span target, bool usePool) + { + DemandScalar(); + + if (IsInlineScalar && usePool) + { + // grow to the correct size in advance, if needed + var length = ScalarLength(); + if (length > target.Length) + { + var bigger = ArrayPool.Shared.Rent(length); + ArrayPool.Shared.Return(pooled); + target = pooled = bigger; + } + } + + var iterator = ScalarChunks(); + ReadOnlySpan current; + int offset = 0; + while (iterator.MoveNext()) + { + // will the current chunk fit? + current = iterator.Current; + if (current.TryCopyTo(target.Slice(offset))) + { + // fits into the current buffer + offset += current.Length; + } + else if (!usePool) + { + // rent disallowed; fill what we can + var available = target.Slice(offset); + current.Slice(0, available.Length).CopyTo(available); + return target; // we filled it + } + else + { + // rent a bigger buffer, copy and recycle + var bigger = ArrayPool.Shared.Rent(offset + current.Length); + if (offset != 0) + { + target.Slice(0, offset).CopyTo(bigger); + } + + ArrayPool.Shared.Return(pooled); + target = pooled = bigger; + current.CopyTo(target.Slice(offset)); + } + } + + return target.Slice(0, offset); + } + + /// + /// Reads the current element using a general purpose byte parser. + /// + /// The type of data being parsed. + internal readonly T ParseChars(Parser parser) + { + byte[] bArr = []; + char[] cArr = []; + try + { + var bSpan = Buffer(ref bArr, stackalloc byte[128]); + var maxChars = RespConstants.UTF8.GetMaxCharCount(bSpan.Length); + Span cSpan = maxChars <= 128 ? stackalloc char[128] : (cArr = ArrayPool.Shared.Rent(maxChars)); + int chars = RespConstants.UTF8.GetChars(bSpan, cSpan); + return parser(cSpan.Slice(0, chars)); + } + finally + { + ArrayPool.Shared.Return(bArr); + ArrayPool.Shared.Return(cArr); + } + } + + /// + /// Reads the current element using a general purpose byte parser. + /// + /// The type of data being parsed. + /// State required by the parser. + internal readonly T ParseChars(Parser parser, TState? state) + { + byte[] bArr = []; + char[] cArr = []; + try + { + var bSpan = Buffer(ref bArr, stackalloc byte[128]); + var maxChars = RespConstants.UTF8.GetMaxCharCount(bSpan.Length); + Span cSpan = maxChars <= 128 ? stackalloc char[128] : (cArr = ArrayPool.Shared.Rent(maxChars)); + int chars = RespConstants.UTF8.GetChars(bSpan, cSpan); + return parser(cSpan.Slice(0, chars), state); + } + finally + { + ArrayPool.Shared.Return(bArr); + ArrayPool.Shared.Return(cArr); + } + } + +#if NET7_0_OR_GREATER + /// + /// Reads the current element using . + /// + /// The type of data being parsed. +#pragma warning disable RS0016, RS0027 // back-compat overload + public readonly T ParseChars(IFormatProvider? formatProvider = null) where T : ISpanParsable +#pragma warning restore RS0016, RS0027 // back-compat overload + { + byte[] bArr = []; + char[] cArr = []; + try + { + var bSpan = Buffer(ref bArr, stackalloc byte[128]); + var maxChars = RespConstants.UTF8.GetMaxCharCount(bSpan.Length); + Span cSpan = maxChars <= 128 ? stackalloc char[128] : (cArr = ArrayPool.Shared.Rent(maxChars)); + int chars = RespConstants.UTF8.GetChars(bSpan, cSpan); + return T.Parse(cSpan.Slice(0, chars), formatProvider ?? CultureInfo.InvariantCulture); + } + finally + { + ArrayPool.Shared.Return(bArr); + ArrayPool.Shared.Return(cArr); + } + } +#endif + +#if NET8_0_OR_GREATER + /// + /// Reads the current element using . + /// + /// The type of data being parsed. +#pragma warning disable RS0016, RS0027 // back-compat overload + public readonly T ParseBytes(IFormatProvider? formatProvider = null) where T : IUtf8SpanParsable +#pragma warning restore RS0016, RS0027 // back-compat overload + { + byte[] bArr = []; + try + { + var bSpan = Buffer(ref bArr, stackalloc byte[128]); + return T.Parse(bSpan, formatProvider ?? CultureInfo.InvariantCulture); + } + finally + { + ArrayPool.Shared.Return(bArr); + } + } +#endif + + /// + /// General purpose parsing callback. + /// + /// The type of source data being parsed. + /// State required by the parser. + /// The output type of data being parsed. + // is this needed? + internal delegate TValue Parser(scoped ReadOnlySpan value, TState? state); + + /// + /// General purpose parsing callback. + /// + /// The type of source data being parsed. + /// The output type of data being parsed. + // is this needed? + internal delegate TValue Parser(scoped ReadOnlySpan value); + + /// + /// Scalar parsing callback that returns a boolean indicating success. + /// + /// The type of source data being parsed. + /// The output type of data being parsed. + public delegate bool ScalarParser(scoped ReadOnlySpan value, out TValue result); + + /// + /// Initializes a new instance of the struct. + /// + /// The raw contents to parse with this instance. + public RespReader(ReadOnlySpan value) + { + _length = 0; + _flags = RespFlags.None; + _prefix = RespPrefix.None; + SetCurrent(value); + + _remainingTailLength = _positionBase = 0; + _tail = null; + } + + private void MovePastCurrent() + { + // skip past the trailing portion of a value, if any + var skip = TrailingLength; + if (_bufferIndex + skip <= CurrentLength) + { + _bufferIndex += skip; // available in the current buffer + } + else + { + AdvanceSlow(skip); + } + + // reset the current state + _length = 0; + _flags = 0; + _prefix = RespPrefix.None; + } + + /// + public RespReader(scoped in ReadOnlySequence value) +#if NETCOREAPP3_0_OR_GREATER + : this(value.FirstSpan) +#else + : this(value.First.Span) +#endif + { + if (!value.IsSingleSegment) + { + _remainingTailLength = value.Length - CurrentLength; + _tail = (value.Start.GetObject() as ReadOnlySequenceSegment)?.Next ?? MissingNext(); + } + + [MethodImpl(MethodImplOptions.NoInlining), DoesNotReturn] + static ReadOnlySequenceSegment MissingNext() => + throw new ArgumentException("Unable to extract tail segment", nameof(value)); + } + + /// + /// Attempt to move to the next RESP element. + /// + /// Unless you are intentionally handling errors, attributes and streaming data, should be preferred. + [EditorBrowsable(EditorBrowsableState.Never), Browsable(false)] + [Obsolete("Unless you are manually handling errors, attributes and streaming data, TryMoveNext() should be preferred.", false)] + public unsafe bool TryReadNext() + { + MovePastCurrent(); + +#if NETCOREAPP3_0_OR_GREATER + // check what we have available; don't worry about zero/fetching the next segment; this is only + // for SIMD lookup, and zero would only apply when data ends exactly on segment boundaries, which + // is incredible niche + var available = CurrentAvailable; + + if (Avx2.IsSupported && Bmi1.IsSupported && available >= sizeof(uint)) + { + // read the first 4 bytes + ref byte origin = ref UnsafeCurrent; + var comparand = Unsafe.ReadUnaligned(ref origin); + + // broadcast those 4 bytes into a vector, mask to get just the first and last byte, and apply a SIMD equality test with our known cases + var eqs = + Avx2.CompareEqual(Avx2.And(Avx2.BroadcastScalarToVector256(&comparand), Raw.FirstLastMask), Raw.CommonRespPrefixes); + + // reinterpret that as floats, and pick out the sign bits (which will be 1 for "equal", 0 for "not equal"); since the + // test cases are mutually exclusive, we expect zero or one matches, so: lzcount tells us which matched + var index = + Bmi1.TrailingZeroCount((uint)Avx.MoveMask(Unsafe.As, Vector256>(ref eqs))); + int len; +#if DEBUG + if (VectorizeDisabled) index = uint.MaxValue; // just to break the switch +#endif + switch (index) + { + case Raw.CommonRespIndex_Success when available >= 5 && Unsafe.Add(ref origin, 4) == (byte)'\n': + _prefix = RespPrefix.SimpleString; + _length = 2; + _bufferIndex++; + _flags = RespFlags.IsScalar | RespFlags.IsInlineScalar; + return true; + case Raw.CommonRespIndex_SingleDigitInteger when Unsafe.Add(ref origin, 2) == (byte)'\r': + _prefix = RespPrefix.Integer; + _length = 1; + _bufferIndex++; + _flags = RespFlags.IsScalar | RespFlags.IsInlineScalar; + return true; + case Raw.CommonRespIndex_DoubleDigitInteger when available >= 5 && Unsafe.Add(ref origin, 4) == (byte)'\n': + _prefix = RespPrefix.Integer; + _length = 2; + _bufferIndex++; + _flags = RespFlags.IsScalar | RespFlags.IsInlineScalar; + return true; + case Raw.CommonRespIndex_SingleDigitString when Unsafe.Add(ref origin, 2) == (byte)'\r': + if (comparand == RespConstants.BulkStringStreaming) + { + _flags = RespFlags.IsScalar | RespFlags.IsStreaming; + } + else + { + len = ParseSingleDigit(Unsafe.Add(ref origin, 1)); + if (available < len + 6) break; // need more data + + UnsafeAssertClLf(4 + len); + _length = len; + _flags = RespFlags.IsScalar | RespFlags.IsInlineScalar; + } + _prefix = RespPrefix.BulkString; + _bufferIndex += 4; + return true; + case Raw.CommonRespIndex_DoubleDigitString when available >= 5 && Unsafe.Add(ref origin, 4) == (byte)'\n': + if (comparand == RespConstants.BulkStringNull) + { + _length = 0; + _flags = RespFlags.IsScalar | RespFlags.IsNull; + } + else + { + len = ParseDoubleDigitsNonNegative(ref Unsafe.Add(ref origin, 1)); + if (available < len + 7) break; // need more data + + UnsafeAssertClLf(5 + len); + _length = len; + _flags = RespFlags.IsScalar | RespFlags.IsInlineScalar; + } + _prefix = RespPrefix.BulkString; + _bufferIndex += 5; + return true; + case Raw.CommonRespIndex_SingleDigitArray when Unsafe.Add(ref origin, 2) == (byte)'\r': + if (comparand == RespConstants.ArrayStreaming) + { + _flags = RespFlags.IsAggregate | RespFlags.IsStreaming; + } + else + { + _flags = RespFlags.IsAggregate; + _length = ParseSingleDigit(Unsafe.Add(ref origin, 1)); + } + _prefix = RespPrefix.Array; + _bufferIndex += 4; + return true; + case Raw.CommonRespIndex_DoubleDigitArray when available >= 5 && Unsafe.Add(ref origin, 4) == (byte)'\n': + if (comparand == RespConstants.ArrayNull) + { + _flags = RespFlags.IsAggregate | RespFlags.IsNull; + } + else + { + _length = ParseDoubleDigitsNonNegative(ref Unsafe.Add(ref origin, 1)); + _flags = RespFlags.IsAggregate; + } + _prefix = RespPrefix.Array; + _bufferIndex += 5; + return true; + case Raw.CommonRespIndex_Error: + len = UnsafePastPrefix().IndexOf(RespConstants.CrlfBytes); + if (len < 0) break; // need more data + + _prefix = RespPrefix.SimpleError; + _flags = RespFlags.IsScalar | RespFlags.IsInlineScalar | RespFlags.IsError; + _length = len; + _bufferIndex++; + return true; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static int ParseDoubleDigitsNonNegative(ref byte value) => (10 * ParseSingleDigit(value)) + ParseSingleDigit(Unsafe.Add(ref value, 1)); +#endif + + // no fancy vectorization, but: we can still try to find the payload the fast way in a single segment + if (_bufferIndex + 3 <= CurrentLength) // shortest possible RESP fragment is length 3 + { + var remaining = UnsafePastPrefix(); + switch (_prefix = UnsafePeekPrefix()) + { + case RespPrefix.SimpleString: + case RespPrefix.SimpleError: + case RespPrefix.Integer: + case RespPrefix.Boolean: + case RespPrefix.Double: + case RespPrefix.BigInteger: + // CRLF-terminated + _length = remaining.IndexOf(RespConstants.CrlfBytes); + if (_length < 0) break; // can't find, need more data + _bufferIndex++; // payload follows prefix directly + _flags = RespFlags.IsScalar | RespFlags.IsInlineScalar; + if (_prefix == RespPrefix.SimpleError) _flags |= RespFlags.IsError; + return true; + case RespPrefix.BulkError: + case RespPrefix.BulkString: + case RespPrefix.VerbatimString: + // length prefix with value payload; first, the length + switch (TryReadLengthPrefix(remaining, out _length, out int consumed)) + { + case LengthPrefixResult.Length: + // still need to valid terminating CRLF + if (remaining.Length < consumed + _length + 2) break; // need more data + UnsafeAssertClLf(1 + consumed + _length); + + _flags = RespFlags.IsScalar | RespFlags.IsInlineScalar; + break; + case LengthPrefixResult.Null: + _flags = RespFlags.IsScalar | RespFlags.IsNull; + break; + case LengthPrefixResult.Streaming: + _flags = RespFlags.IsScalar | RespFlags.IsStreaming; + break; + } + + if (_flags == 0) break; // will need more data to know + if (_prefix == RespPrefix.BulkError) _flags |= RespFlags.IsError; + _bufferIndex += 1 + consumed; + return true; + case RespPrefix.StreamContinuation: + // length prefix, possibly with value payload; first, the length + switch (TryReadLengthPrefix(remaining, out _length, out consumed)) + { + case LengthPrefixResult.Length when _length == 0: + // EOF, no payload + _flags = RespFlags + .IsScalar; // don't claim as streaming, we want this to count towards delta-decrement + break; + case LengthPrefixResult.Length: + // still need to valid terminating CRLF + if (remaining.Length < consumed + _length + 2) break; // need more data + UnsafeAssertClLf(1 + consumed + _length); + + _flags = RespFlags.IsScalar | RespFlags.IsInlineScalar | RespFlags.IsStreaming; + break; + case LengthPrefixResult.Null: + case LengthPrefixResult.Streaming: + ThrowProtocolFailure("Invalid streaming scalar length prefix"); + break; + } + + if (_flags == 0) break; // will need more data to know + _bufferIndex += 1 + consumed; + return true; + case RespPrefix.Array: + case RespPrefix.Set: + case RespPrefix.Map: + case RespPrefix.Push: + case RespPrefix.Attribute: + // length prefix without value payload (child values follow) + switch (TryReadLengthPrefix(remaining, out _length, out consumed)) + { + case LengthPrefixResult.Length: + _flags = RespFlags.IsAggregate; + if (AggregateLengthNeedsDoubling()) _length *= 2; + break; + case LengthPrefixResult.Null: + _flags = RespFlags.IsAggregate | RespFlags.IsNull; + break; + case LengthPrefixResult.Streaming: + _flags = RespFlags.IsAggregate | RespFlags.IsStreaming; + break; + } + + if (_flags == 0) break; // will need more data to know + if (_prefix is RespPrefix.Attribute) _flags |= RespFlags.IsAttribute; + _bufferIndex += consumed + 1; + return true; + case RespPrefix.Null: // null + // note we already checked we had 3 bytes + UnsafeAssertClLf(1); + // treat as both scalar and aggregate; this might seem weird, but makes + // sense when considering how .IsScalar and .IsAggregate are typically used, + // and that a pure null can apply to either + _flags = RespFlags.IsScalar | RespFlags.IsAggregate | RespFlags.IsNull; + _bufferIndex += 3; // skip prefix+terminator + return true; + case RespPrefix.StreamTerminator: + // note we already checked we had 3 bytes + UnsafeAssertClLf(1); + _flags = RespFlags.IsAggregate; // don't claim as streaming - this counts towards delta + _bufferIndex += 3; // skip prefix+terminator + return true; + default: + ThrowProtocolFailure("Unexpected protocol prefix: " + _prefix); + return false; + } + } + + return TryReadNextSlow(ref this); + } + + private static bool TryReadNextSlow(ref RespReader live) + { + // in the case of failure, we don't want to apply any changes, + // so we work against an isolated copy until we're happy + live.MovePastCurrent(); + RespReader isolated = live; + + int next = isolated.RawTryReadByte(); + if (next < 0) return false; + + switch (isolated._prefix = (RespPrefix)next) + { + case RespPrefix.SimpleString: + case RespPrefix.SimpleError: + case RespPrefix.Integer: + case RespPrefix.Boolean: + case RespPrefix.Double: + case RespPrefix.BigInteger: + // CRLF-terminated + if (!isolated.RawTryFindCrLf(out isolated._length)) return false; + isolated._flags = RespFlags.IsScalar | RespFlags.IsInlineScalar; + if (isolated._prefix == RespPrefix.SimpleError) isolated._flags |= RespFlags.IsError; + break; + case RespPrefix.BulkError: + case RespPrefix.BulkString: + case RespPrefix.VerbatimString: + // length prefix with value payload + switch (isolated.RawTryReadLengthPrefix()) + { + case LengthPrefixResult.Length: + // still need to valid terminating CRLF + isolated._flags = RespFlags.IsScalar | RespFlags.IsInlineScalar; + if (!isolated.RawTryAssertInlineScalarPayloadCrLf()) return false; + break; + case LengthPrefixResult.Null: + isolated._flags = RespFlags.IsScalar | RespFlags.IsNull; + break; + case LengthPrefixResult.Streaming: + isolated._flags = RespFlags.IsScalar | RespFlags.IsStreaming; + break; + case LengthPrefixResult.NeedMoreData: + return false; + default: + live.ThrowProtocolFailure("Unexpected length prefix"); + return false; + } + + if (isolated._prefix == RespPrefix.BulkError) isolated._flags |= RespFlags.IsError; + break; + case RespPrefix.Array: + case RespPrefix.Set: + case RespPrefix.Map: + case RespPrefix.Push: + case RespPrefix.Attribute: + // length prefix without value payload (child values follow) + switch (isolated.RawTryReadLengthPrefix()) + { + case LengthPrefixResult.Length: + isolated._flags = RespFlags.IsAggregate; + if (isolated.AggregateLengthNeedsDoubling()) isolated._length *= 2; + break; + case LengthPrefixResult.Null: + isolated._flags = RespFlags.IsAggregate | RespFlags.IsNull; + break; + case LengthPrefixResult.Streaming: + isolated._flags = RespFlags.IsAggregate | RespFlags.IsStreaming; + break; + case LengthPrefixResult.NeedMoreData: + return false; + default: + isolated.ThrowProtocolFailure("Unexpected length prefix"); + return false; + } + + if (isolated._prefix is RespPrefix.Attribute) isolated._flags |= RespFlags.IsAttribute; + break; + case RespPrefix.Null: // null + if (!isolated.RawAssertCrLf()) return false; + isolated._flags = RespFlags.IsScalar | RespFlags.IsNull; + break; + case RespPrefix.StreamTerminator: + if (!isolated.RawAssertCrLf()) return false; + isolated._flags = RespFlags.IsAggregate; // don't claim as streaming - this counts towards delta + break; + case RespPrefix.StreamContinuation: + // length prefix, possibly with value payload; first, the length + switch (isolated.RawTryReadLengthPrefix()) + { + case LengthPrefixResult.Length when isolated._length == 0: + // EOF, no payload + isolated._flags = + RespFlags + .IsScalar; // don't claim as streaming, we want this to count towards delta-decrement + break; + case LengthPrefixResult.Length: + // still need to valid terminating CRLF + isolated._flags = RespFlags.IsScalar | RespFlags.IsInlineScalar | RespFlags.IsStreaming; + if (!isolated.RawTryAssertInlineScalarPayloadCrLf()) return false; // need more data + break; + case LengthPrefixResult.Null: + case LengthPrefixResult.Streaming: + isolated.ThrowProtocolFailure("Invalid streaming scalar length prefix"); + break; + case LengthPrefixResult.NeedMoreData: + default: + return false; + } + + break; + default: + isolated.ThrowProtocolFailure("Unexpected protocol prefix: " + isolated._prefix); + return false; + } + + // commit the speculative changes back, and accept + live = isolated; + return true; + } + + private void AdvanceSlow(long bytes) + { + while (bytes > 0) + { + var available = CurrentLength - _bufferIndex; + if (bytes <= available) + { + _bufferIndex += (int)bytes; + return; + } + + bytes -= available; + + if (!TryMoveToNextSegment()) Throw(); + } + + [DoesNotReturn] + static void Throw() => throw new EndOfStreamException( + "Unexpected end of payload; this is unexpected because we already validated that it was available!"); + } + + private bool AggregateLengthNeedsDoubling() => _prefix is RespPrefix.Map or RespPrefix.Attribute; + + private bool TryMoveToNextSegment() + { + while (_tail is not null && _remainingTailLength > 0) + { + var memory = _tail.Memory; + _tail = _tail.Next; + if (!memory.IsEmpty) + { + var span = memory.Span; // check we can get this before mutating anything + _positionBase += CurrentLength; + if (span.Length > _remainingTailLength) + { + span = span.Slice(0, (int)_remainingTailLength); + _remainingTailLength = 0; + } + else + { + _remainingTailLength -= span.Length; + } + + SetCurrent(span); + _bufferIndex = 0; + return true; + } + } + + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal readonly bool IsOK() // go mad with this, because it is used so often + { + if (TryGetSpan(out var span) && span.Length == 2) + { + var u16 = Unsafe.ReadUnaligned(ref UnsafeCurrent); + return u16 == RespConstants.OKUInt16 | u16 == RespConstants.OKUInt16_LC; + } + + return IsSlow(RespConstants.OKBytes, RespConstants.OKBytes_LC); + } + + /// + /// Indicates whether the current element is a scalar with a value that matches the provided . + /// + /// The payload value to verify. + public readonly bool Is(ReadOnlySpan value) + => TryGetSpan(out var span) ? span.SequenceEqual(value) : IsSlow(value); + + /// + /// Indicates whether the current element is a scalar with a value that starts with the provided . + /// + /// The payload value to verify. + public readonly bool StartsWith(ReadOnlySpan value) + => TryGetSpan(out var span) ? span.StartsWith(value) : StartsWithSlow(value); + + /// + /// Indicates whether the current element is a scalar with a value that matches the provided . + /// + /// The payload value to verify. + public readonly bool Is(ReadOnlySpan value) + { + var bytes = RespConstants.UTF8.GetMaxByteCount(value.Length); + byte[]? oversized = null; + Span buffer = bytes <= 128 ? stackalloc byte[128] : (oversized = ArrayPool.Shared.Rent(bytes)); + bytes = RespConstants.UTF8.GetBytes(value, buffer); + bool result = Is(buffer.Slice(0, bytes)); + if (oversized is not null) ArrayPool.Shared.Return(oversized); + return result; + } + + internal readonly bool IsInlneCpuUInt32(uint value) + { + if (IsInlineScalar && _length == sizeof(uint)) + { + return CurrentAvailable >= sizeof(uint) + ? Unsafe.ReadUnaligned(ref UnsafeCurrent) == value + : SlowIsInlneCpuUInt32(value); + } + + return false; + } + + private readonly bool SlowIsInlneCpuUInt32(uint value) + { + Debug.Assert(IsInlineScalar && _length == sizeof(uint), "should be inline scalar of length 4"); + Span buffer = stackalloc byte[sizeof(uint)]; + var copy = this; + copy.RawFillBytes(buffer); + return RespConstants.UnsafeCpuUInt32(buffer) == value; + } + + /// + /// Indicates whether the current element is a scalar with a value that matches the provided . + /// + /// The payload value to verify. + public readonly bool Is(byte value) + { + if (IsInlineScalar && _length == 1 && CurrentAvailable >= 1) + { + return UnsafeCurrent == value; + } + + ReadOnlySpan span = [value]; + return IsSlow(span); + } + + private readonly bool IsSlow(ReadOnlySpan testValue0, ReadOnlySpan testValue2) + => IsSlow(testValue0) || IsSlow(testValue2); + + private readonly bool IsSlow(ReadOnlySpan testValue) + { + DemandScalar(); + if (IsNull) return false; // nothing equals null + if (TotalAvailable < testValue.Length) return false; + + if (!IsStreaming && testValue.Length != ScalarLength()) return false; + + var iterator = ScalarChunks(); + while (true) + { + if (testValue.IsEmpty) + { + // nothing left to test; if also nothing left to read, great! + return !iterator.MoveNext(); + } + + if (!iterator.MoveNext()) + { + return false; // test is longer + } + + var current = iterator.Current; + if (testValue.Length < current.Length) return false; // payload is longer + + if (!current.SequenceEqual(testValue.Slice(0, current.Length))) return false; // payload is different + + testValue = testValue.Slice(current.Length); // validated; continue + } + } + + private readonly bool StartsWithSlow(ReadOnlySpan testValue) + { + DemandScalar(); + if (IsNull) return false; // nothing equals null + if (testValue.IsEmpty) return true; // every non-null scalar starts-with empty + if (TotalAvailable < testValue.Length) return false; + + if (!IsStreaming && testValue.Length < ScalarLength()) return false; + + var iterator = ScalarChunks(); + while (true) + { + if (testValue.IsEmpty) + { + return true; + } + + if (!iterator.MoveNext()) + { + return false; // test is longer + } + + var current = iterator.Current; + if (testValue.Length <= current.Length) + { + // current fragment exhausts the test data; check it with StartsWith + return testValue.StartsWith(current); + } + + // current fragment is longer than the test data; the overlap must match exactly + if (!current.SequenceEqual(testValue.Slice(0, current.Length))) return false; // payload is different + + testValue = testValue.Slice(current.Length); // validated; continue + } + } + + /// + /// Copy the current scalar value out into the supplied , or as much as can be copied. + /// + /// The destination for the copy operation. + /// The number of bytes successfully copied. + public readonly int CopyTo(scoped Span target) + { + if (TryGetSpan(out var value)) + { + if (target.Length < value.Length) value = value.Slice(0, target.Length); + + value.CopyTo(target); + return value.Length; + } + + int totalBytes = 0; + var iterator = ScalarChunks(); + while (iterator.MoveNext()) + { + value = iterator.Current; + if (target.Length <= value.Length) + { + value.Slice(0, target.Length).CopyTo(target); + return totalBytes + target.Length; + } + + value.CopyTo(target); + target = target.Slice(value.Length); + totalBytes += value.Length; + } + + return totalBytes; + } + + /// + /// Copy the current scalar value out into the supplied , or as much as can be copied. + /// + /// The destination for the copy operation. + /// The number of bytes successfully copied. + public readonly int CopyTo(IBufferWriter target) + { + if (TryGetSpan(out var value)) + { + target.Write(value); + return value.Length; + } + + int totalBytes = 0; + var iterator = ScalarChunks(); + while (iterator.MoveNext()) + { + value = iterator.Current; + target.Write(value); + totalBytes += value.Length; + } + + return totalBytes; + } + + /// + /// Asserts that the current element is not null. + /// + public void DemandNotNull() + { + if (IsNull) Throw(); + static void Throw() => throw new InvalidOperationException("A non-null element was expected"); + } + + /// + /// Read the current element as a value. + /// + [SuppressMessage("Style", "IDE0018:Inline variable declaration", Justification = "No it can't - conditional")] + public readonly long ReadInt64() + { + var span = Buffer(stackalloc byte[RespConstants.MaxRawBytesInt64 + 1]); + long value; + if (!(span.Length <= RespConstants.MaxRawBytesInt64 + && Utf8Parser.TryParse(span, out value, out int bytes) + && bytes == span.Length)) + { + ThrowFormatException(); + value = 0; + } + + return value; + } + + /// + /// Try to read the current element as a value. + /// + public readonly bool TryReadInt64(out long value) + { + var span = Buffer(stackalloc byte[RespConstants.MaxRawBytesInt64 + 1]); + if (span.Length <= RespConstants.MaxRawBytesInt64) + { + return Utf8Parser.TryParse(span, out value, out int bytes) & bytes == span.Length; + } + + value = 0; + return false; + } + + /// + /// Read the current element as a value. + /// + [SuppressMessage("Style", "IDE0018:Inline variable declaration", Justification = "No it can't - conditional")] + public readonly int ReadInt32() + { + var span = Buffer(stackalloc byte[RespConstants.MaxRawBytesInt32 + 1]); + int value; + if (!(span.Length <= RespConstants.MaxRawBytesInt32 + && Utf8Parser.TryParse(span, out value, out int bytes) + && bytes == span.Length)) + { + ThrowFormatException(); + value = 0; + } + + return value; + } + + /// + /// Try to read the current element as a value. + /// + public readonly bool TryReadInt32(out int value) + { + var span = Buffer(stackalloc byte[RespConstants.MaxRawBytesInt32 + 1]); + if (span.Length <= RespConstants.MaxRawBytesInt32) + { + return Utf8Parser.TryParse(span, out value, out int bytes) & bytes == span.Length; + } + + value = 0; + return false; + } + + /// + /// Read the current element as a value. + /// + public readonly double ReadDouble() + { + var span = Buffer(stackalloc byte[RespConstants.MaxRawBytesNumber + 1]); + + if (span.Length <= RespConstants.MaxRawBytesNumber + && Utf8Parser.TryParse(span, out double value, out int bytes) + && bytes == span.Length) + { + return value; + } + + switch (span.Length) + { + case 3 when "inf"u8.SequenceEqual(span): + return double.PositiveInfinity; + case 3 when "nan"u8.SequenceEqual(span): + return double.NaN; + case 4 when "+inf"u8.SequenceEqual(span): // not actually mentioned in spec, but: we'll allow it + return double.PositiveInfinity; + case 4 when "-inf"u8.SequenceEqual(span): + return double.NegativeInfinity; + } + + ThrowFormatException(); + return 0; + } + + /// + /// Try to read the current element as a value. + /// + public bool TryReadDouble(out double value, bool allowTokens = true) + { + var span = Buffer(stackalloc byte[RespConstants.MaxRawBytesNumber + 1]); + + if (Utf8Parser.TryParse(span, out value, out int bytes) + && bytes == span.Length) + { + return true; + } + + if (allowTokens) + { + switch (span.Length) + { + case 3 when "inf"u8.SequenceEqual(span): + value = double.PositiveInfinity; + return true; + case 3 when "nan"u8.SequenceEqual(span): + value = double.NaN; + return true; + case 4 when "+inf"u8.SequenceEqual(span): // not actually mentioned in spec, but: we'll allow it + value = double.PositiveInfinity; + return true; + case 4 when "-inf"u8.SequenceEqual(span): + value = double.NegativeInfinity; + return true; + } + } + + value = 0; + return false; + } + + /// + /// Note this uses a stackalloc buffer; requesting too much may overflow the stack. + /// + internal readonly bool UnsafeTryReadShortAscii(out string value, int maxLength = 127) + { + var span = Buffer(stackalloc byte[maxLength + 1]); + value = ""; + if (span.IsEmpty) return true; + + if (span.Length <= maxLength) + { + // check for anything that looks binary or unicode + foreach (var b in span) + { + // allow [SPACE]-thru-[DEL], plus CR/LF + if (!(b < 127 & (b >= 32 | (b is 12 or 13)))) + { + return false; + } + } + + value = Encoding.UTF8.GetString(span); + return true; + } + + return false; + } + + /// + /// Read the current element as a value. + /// + [SuppressMessage("Style", "IDE0018:Inline variable declaration", Justification = "No it can't - conditional")] + public readonly decimal ReadDecimal() + { + var span = Buffer(stackalloc byte[RespConstants.MaxRawBytesNumber + 1]); + decimal value; + if (!(span.Length <= RespConstants.MaxRawBytesNumber + && Utf8Parser.TryParse(span, out value, out int bytes) + && bytes == span.Length)) + { + ThrowFormatException(); + value = 0; + } + + return value; + } + + /// + /// Read the current element as a value. + /// + public readonly bool ReadBoolean() + { + var span = Buffer(stackalloc byte[2]); + switch (span.Length) + { + case 1: + switch (span[0]) + { + case (byte)'0' when Prefix == RespPrefix.Integer: return false; + case (byte)'1' when Prefix == RespPrefix.Integer: return true; + case (byte)'f' when Prefix == RespPrefix.Boolean: return false; + case (byte)'t' when Prefix == RespPrefix.Boolean: return true; + } + + break; + case 2 when Prefix == RespPrefix.SimpleString && IsOK(): return true; + } + + ThrowFormatException(); + return false; + } + + /// + /// Parse a scalar value as an enum of type . + /// + /// The value to report if the value is not recognized. + /// The type of enum being parsed. + public readonly T ReadEnum(T unknownValue = default) where T : struct, Enum + { +#if NET6_0_OR_GREATER + return ParseChars(static (chars, state) => Enum.TryParse(chars, true, out T value) ? value : state, unknownValue); +#else + return Enum.TryParse(ReadString(), true, out T value) ? value : unknownValue; +#endif + } + +#pragma warning disable RS0026 // unambiguous due to signature + /// + /// Reads an aggregate as an array of elements without changing the position. + /// + /// The type of data to be projected. + public TResult[]? ReadArray(Projection projection, bool scalar = false) + { + var copy = this; + return copy.ReadPastArray(projection, scalar); + } + + /// + /// Reads an aggregate as an array of elements without changing the position. + /// + /// Additional state required by the projection. + /// The type of data to be projected. + public TResult[]? ReadArray(ref TState state, Projection projection, bool scalar = false) +#if NET9_0_OR_GREATER + where TState : allows ref struct +#endif + { + var copy = this; + return copy.ReadPastArray(ref state, projection, scalar); + } + + /// + /// Reads an aggregate as an array of elements, moving past the data as a side effect. + /// + /// The type of data to be projected. + public TResult[]? ReadPastArray(Projection projection, bool scalar = false) + => ReadPastArray(ref projection, static (ref projection, ref reader) => projection(ref reader), scalar); + + /// + /// Reads an aggregate as an array of elements, moving past the data as a side effect. + /// + /// Additional state required by the projection. + /// The type of data to be projected. + public TResult[]? ReadPastArray(ref TState state, Projection projection, bool scalar = false) +#if NET9_0_OR_GREATER + where TState : allows ref struct +#endif +#pragma warning restore RS0026 + { + DemandAggregate(); + if (IsNull) return null; + var len = AggregateLength(); + if (len == 0) return []; + var result = new TResult[len]; + if (scalar) + { + // if the data to be consumed is simple (scalar), we can use + // a simpler path that doesn't need to worry about RESP subtrees + for (int i = 0; i < result.Length; i++) + { + MoveNextScalar(); + result[i] = projection(ref state, ref this); + } + } + else + { + var agg = AggregateChildren(); + agg.FillAll(result, ref state, projection); + agg.MovePast(out this); + } + + return result; + } + + public TResult[]? ReadPairArray( + Projection first, + Projection second, + Func combine, + bool scalar = true) + { + DemandAggregate(); + if (IsNull) return null; + int sourceLength = AggregateLength(); + if (sourceLength is 0 or 1) return []; + var result = new TResult[sourceLength >> 1]; + if (scalar) + { + // if the data to be consumed is simple (scalar), we can use + // a simpler path that doesn't need to worry about RESP subtrees + for (int i = 0; i < result.Length; i++) + { + MoveNextScalar(); + var x = first(ref this); + MoveNextScalar(); + var y = second(ref this); + result[i] = combine(x, y); + } + // if we have an odd number of source elements, skip the last one + if ((sourceLength & 1) != 0) MoveNextScalar(); + } + else + { + var agg = AggregateChildren(); + agg.FillAll(result, first, second, combine); + agg.MovePast(out this); + } + return result; + } + internal TResult[]? ReadLeasedPairArray( + Projection first, + Projection second, + Func combine, + out int count, + bool scalar = true) + { + DemandAggregate(); + if (IsNull) + { + count = 0; + return null; + } + int sourceLength = AggregateLength(); + count = sourceLength >> 1; + if (count is 0) return []; + + var oversized = ArrayPool.Shared.Rent(count); + var result = oversized.AsSpan(0, count); + if (scalar) + { + // if the data to be consumed is simple (scalar), we can use + // a simpler path that doesn't need to worry about RESP subtrees + for (int i = 0; i < result.Length; i++) + { + MoveNextScalar(); + var x = first(ref this); + MoveNextScalar(); + var y = second(ref this); + result[i] = combine(x, y); + } + // if we have an odd number of source elements, skip the last one + if ((sourceLength & 1) != 0) MoveNextScalar(); + } + else + { + var agg = AggregateChildren(); + agg.FillAll(result, first, second, combine); + agg.MovePast(out this); + } + return oversized; + } +} diff --git a/src/RESPite/Messages/RespScanState.cs b/src/RESPite/Messages/RespScanState.cs new file mode 100644 index 000000000..37cd3f8b6 --- /dev/null +++ b/src/RESPite/Messages/RespScanState.cs @@ -0,0 +1,163 @@ +using System.Buffers; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace RESPite.Messages; + +/// +/// Holds state used for RESP frame parsing, i.e. detecting the RESP for an entire top-level message. +/// +[Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)] +public struct RespScanState +{ + /* + The key point of ScanState is to skim over a RESP stream with minimal frame processing, to find the + end of a single top-level RESP message. We start by expecting 1 message, and then just read, with the + rules that the end of a message subtracts one, and aggregates add N. Streaming scalars apply zero offset + until the scalar stream terminator. Attributes also apply zero offset. + Note that streaming aggregates change the rules - when at least one streaming aggregate is in effect, + no offsets are applied until we get back out of the outermost streaming aggregate - we achieve this + by simply counting the streaming aggregate depth, which is usually zero. + Note that in reality streaming (scalar and aggregates) and attributes are non-existent; in addition + to being specific to RESP3, no known server currently implements these parts of the RESP3 specification, + so everything here is theoretical, but: works according to the spec. + */ + private int _delta; // when this becomes -1, we have fully read a top-level message; + private ushort _streamingAggregateDepth; + private RespPrefix _prefix; + + public RespPrefix Prefix => _prefix; + + private long _totalBytes; +#if DEBUG + private int _elementCount; + + /// + public override string ToString() => $"{_prefix}, consumed: {_totalBytes} bytes, {_elementCount} nodes, complete: {IsComplete} ({_delta + 1} outstanding)"; +#else + /// + public override string ToString() => _prefix.ToString(); +#endif + + /// + public override bool Equals([NotNullWhen(true)] object? obj) => throw new NotSupportedException(); + + /// + public override int GetHashCode() => throw new NotSupportedException(); + + /// + /// Gets whether an entire top-level RESP message has been consumed. + /// + public bool IsComplete => _delta == -1; + + /// + /// Gets the total length of the payload read (or read so far, if it is not yet complete); this combines payloads from multiple + /// TryRead operations. + /// + public long TotalBytes => _totalBytes; + + // used when spotting common replies - we entirely bypass the usual reader/delta mechanism + internal void SetComplete(int totalBytes, RespPrefix prefix) + { + _totalBytes = totalBytes; + _delta = -1; + _prefix = prefix; +#if DEBUG + _elementCount = 1; +#endif + } + + /// + /// The amount of data, in bytes, to read before attempting to read the next frame. + /// + public const int MinBytes = 3; // minimum legal RESP frame is: _\r\n + + /// + /// Create a new value that can parse the supplied node (and subtree). + /// + internal RespScanState(in RespReader reader) + { + Debug.Assert(reader.Prefix != RespPrefix.None, "missing RESP prefix"); + _totalBytes = 0; + _delta = reader.GetInitialScanCount(out _streamingAggregateDepth); + } + + /// + /// Scan as far as possible, stopping when an entire top-level RESP message has been consumed or the data is exhausted. + /// + /// True if a top-level RESP message has been consumed. + public bool TryRead(ref RespReader reader, out long bytesRead) + { + bytesRead = ReadCore(ref reader, reader.BytesConsumed); + return IsComplete; + } + + /// + /// Scan as far as possible, stopping when an entire top-level RESP message has been consumed or the data is exhausted. + /// + /// True if a top-level RESP message has been consumed. + public bool TryRead(ReadOnlySpan value, out int bytesRead) + { + var reader = new RespReader(value); + bytesRead = (int)ReadCore(ref reader); + return IsComplete; + } + + /// + /// Scan as far as possible, stopping when an entire top-level RESP message has been consumed or the data is exhausted. + /// + /// True if a top-level RESP message has been consumed. + public bool TryRead(in ReadOnlySequence value, out long bytesRead) + { + var reader = new RespReader(in value); + bytesRead = ReadCore(ref reader); + return IsComplete; + } + + /// + /// Scan as far as possible, stopping when an entire top-level RESP message has been consumed or the data is exhausted. + /// + /// The number of bytes consumed in this operation. + private long ReadCore(ref RespReader reader, long startOffset = 0) + { +#pragma warning disable CS0618 // avoid TryReadNext unless you know what you're doing + while (_delta >= 0 && reader.TryReadNext()) +#pragma warning restore CS0618 + { +#if DEBUG + _elementCount++; +#endif + if (!reader.IsAttribute & _prefix == RespPrefix.None) + { + _prefix = reader.Prefix; + } + + if (reader.IsNonNullAggregate) ApplyAggregateRules(ref reader); + + if (_streamingAggregateDepth == 0) _delta += reader.Delta(); + } + + var bytesRead = reader.BytesConsumed - startOffset; + _totalBytes += bytesRead; + return bytesRead; + } + + private void ApplyAggregateRules(ref RespReader reader) + { + Debug.Assert(reader.IsAggregate, "RESP aggregate expected"); + if (reader.IsStreaming) + { + // entering an aggregate stream + if (_streamingAggregateDepth == ushort.MaxValue) ThrowTooDeep(); + _streamingAggregateDepth++; + } + else if (reader.Prefix == RespPrefix.StreamTerminator) + { + // exiting an aggregate stream + if (_streamingAggregateDepth == 0) ThrowUnexpectedTerminator(); + _streamingAggregateDepth--; + } + static void ThrowTooDeep() => throw new InvalidOperationException("Maximum streaming aggregate depth exceeded."); + static void ThrowUnexpectedTerminator() => throw new InvalidOperationException("Unexpected streaming aggregate terminator."); + } +} diff --git a/src/RESPite/PublicAPI/PublicAPI.Shipped.txt b/src/RESPite/PublicAPI/PublicAPI.Shipped.txt new file mode 100644 index 000000000..ab058de62 --- /dev/null +++ b/src/RESPite/PublicAPI/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt b/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt new file mode 100644 index 000000000..9ce6685bc --- /dev/null +++ b/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt @@ -0,0 +1,214 @@ +#nullable enable +[SER004]const RESPite.Buffers.CycleBuffer.GetAnything = 0 -> int +[SER004]const RESPite.Buffers.CycleBuffer.GetFullPagesOnly = -1 -> int +[SER004]override RESPite.AsciiHash.Equals(object? other) -> bool +[SER004]override RESPite.AsciiHash.GetHashCode() -> int +[SER004]override RESPite.AsciiHash.ToString() -> string! +[SER004]RESPite.AsciiHash +[SER004]RESPite.AsciiHash.AsciiHash() -> void +[SER004]RESPite.AsciiHash.AsciiHash(byte[]! arr) -> void +[SER004]RESPite.AsciiHash.AsciiHash(byte[]! arr, int index, int length) -> void +[SER004]RESPite.AsciiHash.AsciiHash(System.ReadOnlySpan value) -> void +[SER004]RESPite.AsciiHash.BufferLength.get -> int +[SER004]RESPite.AsciiHash.Equals(in RESPite.AsciiHash other) -> bool +[SER004]RESPite.AsciiHash.IsCI(System.ReadOnlySpan value) -> bool +[SER004]RESPite.AsciiHash.IsCS(System.ReadOnlySpan value) -> bool +[SER004]RESPite.AsciiHash.Length.get -> int +[SER004]RESPite.AsciiHash.Span.get -> System.ReadOnlySpan +[SER004]RESPite.AsciiHashAttribute +[SER004]RESPite.AsciiHashAttribute.AsciiHashAttribute(string! token = "") -> void +[SER004]RESPite.AsciiHashAttribute.CaseSensitive.get -> bool +[SER004]RESPite.AsciiHashAttribute.CaseSensitive.set -> void +[SER004]RESPite.AsciiHashAttribute.Token.get -> string! +[SER004]RESPite.AsciiHash.AsciiHash(string? value) -> void +[SER004]RESPite.AsciiHash.IsEmpty.get -> bool +[SER004]RESPite.Buffers.CycleBuffer +[SER004]RESPite.Buffers.CycleBuffer.Commit(int count) -> void +[SER004]RESPite.Buffers.CycleBuffer.CommittedIsEmpty.get -> bool +[SER004]RESPite.Buffers.CycleBuffer.CycleBuffer() -> void +[SER004]RESPite.Buffers.CycleBuffer.DiscardCommitted(int count) -> void +[SER004]RESPite.Buffers.CycleBuffer.DiscardCommitted(long count) -> void +[SER004]RESPite.Buffers.CycleBuffer.GetAllCommitted() -> System.Buffers.ReadOnlySequence +[SER004]RESPite.Buffers.CycleBuffer.GetCommittedLength() -> long +[SER004]RESPite.Buffers.CycleBuffer.GetUncommittedMemory(int hint = 0) -> System.Memory +[SER004]RESPite.Buffers.CycleBuffer.GetUncommittedSpan(int hint = 0) -> System.Span +[SER004]RESPite.Buffers.CycleBuffer.PageSize.get -> int +[SER004]RESPite.Buffers.CycleBuffer.Pool.get -> System.Buffers.MemoryPool! +[SER004]RESPite.Buffers.CycleBuffer.Release() -> void +[SER004]RESPite.Buffers.CycleBuffer.TryGetCommitted(out System.ReadOnlySpan span) -> bool +[SER004]RESPite.Buffers.CycleBuffer.TryGetFirstCommittedMemory(int minBytes, out System.ReadOnlyMemory memory) -> bool +[SER004]RESPite.Buffers.CycleBuffer.TryGetFirstCommittedSpan(int minBytes, out System.ReadOnlySpan span) -> bool +[SER004]RESPite.Buffers.CycleBuffer.UncommittedAvailable.get -> int +[SER004]RESPite.Buffers.CycleBuffer.Write(in System.Buffers.ReadOnlySequence value) -> void +[SER004]RESPite.Buffers.CycleBuffer.Write(System.ReadOnlySpan value) -> void +[SER004]RESPite.Buffers.ICycleBufferCallback +[SER004]RESPite.Buffers.ICycleBufferCallback.PageComplete() -> void +[SER004]RESPite.Messages.RespReader.AggregateEnumerator.FillAll(scoped System.Span target, RESPite.Messages.RespReader.Projection! projection) -> void +[SER004]RESPite.Messages.RespReader.AggregateEnumerator.FillAll(scoped System.Span target, ref TState state, RESPite.Messages.RespReader.Projection! first, RESPite.Messages.RespReader.Projection! second, System.Func! combine) -> void +[SER004]RESPite.Messages.RespReader.AggregateEnumerator.FillAll(scoped System.Span target, ref TState state, RESPite.Messages.RespReader.Projection! projection) -> void +[SER004]RESPite.Messages.RespReader.AggregateEnumerator.MoveNextRaw() -> bool +[SER004]RESPite.Messages.RespReader.AggregateEnumerator.MoveNextRaw(RESPite.Messages.RespAttributeReader! respAttributeReader, ref T attributes) -> bool +[SER004]RESPite.Messages.RespReader.AggregateIsEmpty() -> bool +[SER004]RESPite.Messages.RespReader.AggregateLengthIs(int count) -> bool +[SER004]RESPite.Messages.RespReader.Clone() -> RESPite.Messages.RespReader +[SER004]RESPite.Messages.RespReader.FillAll(scoped System.Span target, ref TState state, RESPite.Messages.RespReader.Projection! projection) -> void +[SER004]RESPite.Messages.RespReader.Projection +[SER004]RESPite.Messages.RespReader.ReadArray(ref TState state, RESPite.Messages.RespReader.Projection! projection, bool scalar = false) -> TResult[]? +[SER004]RESPite.Messages.RespReader.ReadPastArray(ref TState state, RESPite.Messages.RespReader.Projection! projection, bool scalar = false) -> TResult[]? +[SER004]RESPite.Messages.RespReader.ScalarParser +[SER004]RESPite.Messages.RespReader.TryParseScalar(delegate*, out T, bool> parser, out T value) -> bool +[SER004]RESPite.Messages.RespReader.TryParseScalar(RESPite.Messages.RespReader.ScalarParser! parser, out T value) -> bool +[SER004]static RESPite.AsciiHash.CaseInsensitiveEqualityComparer.get -> System.Collections.Generic.IEqualityComparer! +[SER004]static RESPite.AsciiHash.CaseSensitiveEqualityComparer.get -> System.Collections.Generic.IEqualityComparer! +[SER004]static RESPite.AsciiHash.EqualsCI(System.ReadOnlySpan first, System.ReadOnlySpan second) -> bool +[SER004]static RESPite.AsciiHash.EqualsCI(System.ReadOnlySpan first, System.ReadOnlySpan second) -> bool +[SER004]static RESPite.AsciiHash.EqualsCS(System.ReadOnlySpan first, System.ReadOnlySpan second) -> bool +[SER004]static RESPite.AsciiHash.EqualsCS(System.ReadOnlySpan first, System.ReadOnlySpan second) -> bool +[SER004]static RESPite.AsciiHash.Hash(scoped System.ReadOnlySpan value, out long cs, out long uc) -> void +[SER004]static RESPite.AsciiHash.Hash(scoped System.ReadOnlySpan value, out long cs0, out long uc0, out long cs1, out long uc1) -> void +[SER004]static RESPite.AsciiHash.Hash(scoped System.ReadOnlySpan value, out long cs, out long uc) -> void +[SER004]static RESPite.AsciiHash.Hash(scoped System.ReadOnlySpan value, out long cs0, out long uc0, out long cs1, out long uc1) -> void +[SER004]static RESPite.AsciiHash.HashCS(scoped System.ReadOnlySpan value) -> long +[SER004]static RESPite.AsciiHash.HashCS(scoped System.ReadOnlySpan value, out long cs0, out long cs1) -> void +[SER004]static RESPite.AsciiHash.HashCS(scoped System.ReadOnlySpan value) -> long +[SER004]static RESPite.AsciiHash.HashCS(scoped System.ReadOnlySpan value, out long cs0, out long cs1) -> void +[SER004]static RESPite.AsciiHash.HashUC(scoped System.ReadOnlySpan value) -> long +[SER004]static RESPite.AsciiHash.HashUC(scoped System.ReadOnlySpan value, out long cs0, out long cs1) -> void +[SER004]static RESPite.AsciiHash.HashUC(scoped System.ReadOnlySpan value) -> long +[SER004]static RESPite.AsciiHash.HashUC(scoped System.ReadOnlySpan value, out long cs0, out long cs1) -> void +[SER004]static RESPite.AsciiHash.SequenceEqualsCI(System.ReadOnlySpan first, System.ReadOnlySpan second) -> bool +[SER004]static RESPite.AsciiHash.SequenceEqualsCI(System.ReadOnlySpan first, System.ReadOnlySpan second) -> bool +[SER004]static RESPite.AsciiHash.SequenceEqualsCS(System.ReadOnlySpan first, System.ReadOnlySpan second) -> bool +[SER004]static RESPite.AsciiHash.SequenceEqualsCS(System.ReadOnlySpan first, System.ReadOnlySpan second) -> bool +[SER004]static RESPite.AsciiHash.ToLower(System.Span span) -> void +[SER004]static RESPite.AsciiHash.ToUpper(System.Span span) -> void +[SER004]const RESPite.Messages.RespScanState.MinBytes = 3 -> int +[SER004]override RESPite.Messages.RespScanState.Equals(object? obj) -> bool +[SER004]override RESPite.Messages.RespScanState.GetHashCode() -> int +[SER004]override RESPite.Messages.RespScanState.ToString() -> string! +[SER004]RESPite.Messages.RespAttributeReader +[SER004]RESPite.Messages.RespAttributeReader.RespAttributeReader() -> void +[SER004]RESPite.Messages.RespFrameScanner +[SER004]RESPite.Messages.RespFrameScanner.TryRead(ref RESPite.Messages.RespScanState state, in System.Buffers.ReadOnlySequence data) -> System.Buffers.OperationStatus +[SER004]RESPite.Messages.RespFrameScanner.TryRead(ref RESPite.Messages.RespScanState state, System.ReadOnlySpan data) -> System.Buffers.OperationStatus +[SER004]RESPite.Messages.RespFrameScanner.ValidateRequest(in System.Buffers.ReadOnlySequence message) -> void +[SER004]RESPite.Messages.RespPrefix +[SER004]RESPite.Messages.RespPrefix.Array = 42 -> RESPite.Messages.RespPrefix +[SER004]RESPite.Messages.RespPrefix.Attribute = 124 -> RESPite.Messages.RespPrefix +[SER004]RESPite.Messages.RespPrefix.BigInteger = 40 -> RESPite.Messages.RespPrefix +[SER004]RESPite.Messages.RespPrefix.Boolean = 35 -> RESPite.Messages.RespPrefix +[SER004]RESPite.Messages.RespPrefix.BulkError = 33 -> RESPite.Messages.RespPrefix +[SER004]RESPite.Messages.RespPrefix.BulkString = 36 -> RESPite.Messages.RespPrefix +[SER004]RESPite.Messages.RespPrefix.Double = 44 -> RESPite.Messages.RespPrefix +[SER004]RESPite.Messages.RespPrefix.Integer = 58 -> RESPite.Messages.RespPrefix +[SER004]RESPite.Messages.RespPrefix.Map = 37 -> RESPite.Messages.RespPrefix +[SER004]RESPite.Messages.RespPrefix.None = 0 -> RESPite.Messages.RespPrefix +[SER004]RESPite.Messages.RespPrefix.Null = 95 -> RESPite.Messages.RespPrefix +[SER004]RESPite.Messages.RespPrefix.Push = 62 -> RESPite.Messages.RespPrefix +[SER004]RESPite.Messages.RespPrefix.Set = 126 -> RESPite.Messages.RespPrefix +[SER004]RESPite.Messages.RespPrefix.SimpleError = 45 -> RESPite.Messages.RespPrefix +[SER004]RESPite.Messages.RespPrefix.SimpleString = 43 -> RESPite.Messages.RespPrefix +[SER004]RESPite.Messages.RespPrefix.StreamContinuation = 59 -> RESPite.Messages.RespPrefix +[SER004]RESPite.Messages.RespPrefix.StreamTerminator = 46 -> RESPite.Messages.RespPrefix +[SER004]RESPite.Messages.RespPrefix.VerbatimString = 61 -> RESPite.Messages.RespPrefix +[SER004]RESPite.Messages.RespReader +[SER004]RESPite.Messages.RespReader.AggregateChildren() -> RESPite.Messages.RespReader.AggregateEnumerator +[SER004]RESPite.Messages.RespReader.AggregateEnumerator +[SER004]RESPite.Messages.RespReader.AggregateEnumerator.AggregateEnumerator() -> void +[SER004]RESPite.Messages.RespReader.AggregateEnumerator.AggregateEnumerator(scoped in RESPite.Messages.RespReader reader) -> void +[SER004]RESPite.Messages.RespReader.AggregateEnumerator.Current.get -> RESPite.Messages.RespReader +[SER004]RESPite.Messages.RespReader.AggregateEnumerator.DemandNext() -> void +[SER004]RESPite.Messages.RespReader.AggregateEnumerator.FillAll(scoped System.Span target, RESPite.Messages.RespReader.Projection! first, RESPite.Messages.RespReader.Projection! second, System.Func! combine) -> void +[SER004]RESPite.Messages.RespReader.AggregateEnumerator.GetEnumerator() -> RESPite.Messages.RespReader.AggregateEnumerator +[SER004]RESPite.Messages.RespReader.AggregateEnumerator.MoveNext() -> bool +[SER004]RESPite.Messages.RespReader.AggregateEnumerator.MoveNext(RESPite.Messages.RespPrefix prefix) -> bool +[SER004]RESPite.Messages.RespReader.AggregateEnumerator.MoveNext(RESPite.Messages.RespPrefix prefix, RESPite.Messages.RespAttributeReader! respAttributeReader, ref T attributes) -> bool +[SER004]RESPite.Messages.RespReader.AggregateEnumerator.MovePast(out RESPite.Messages.RespReader reader) -> void +[SER004]RESPite.Messages.RespReader.AggregateEnumerator.ReadOne(RESPite.Messages.RespReader.Projection! projection) -> T +[SER004]RESPite.Messages.RespReader.AggregateEnumerator.Value -> RESPite.Messages.RespReader +[SER004]RESPite.Messages.RespReader.AggregateLength() -> int +[SER004]RESPite.Messages.RespReader.BytesConsumed.get -> long +[SER004]RESPite.Messages.RespReader.CopyTo(scoped System.Span target) -> int +[SER004]RESPite.Messages.RespReader.CopyTo(System.Buffers.IBufferWriter! target) -> int +[SER004]RESPite.Messages.RespReader.DemandAggregate() -> void +[SER004]RESPite.Messages.RespReader.DemandEnd() -> void +[SER004]RESPite.Messages.RespReader.DemandNotNull() -> void +[SER004]RESPite.Messages.RespReader.DemandScalar() -> void +[SER004]RESPite.Messages.RespReader.FillAll(scoped System.Span target, RESPite.Messages.RespReader.Projection! projection) -> void +[SER004]RESPite.Messages.RespReader.Is(byte value) -> bool +[SER004]RESPite.Messages.RespReader.Is(System.ReadOnlySpan value) -> bool +[SER004]RESPite.Messages.RespReader.Is(System.ReadOnlySpan value) -> bool +[SER004]RESPite.Messages.RespReader.IsAggregate.get -> bool +[SER004]RESPite.Messages.RespReader.IsAttribute.get -> bool +[SER004]RESPite.Messages.RespReader.IsError.get -> bool +[SER004]RESPite.Messages.RespReader.IsNull.get -> bool +[SER004]RESPite.Messages.RespReader.IsScalar.get -> bool +[SER004]RESPite.Messages.RespReader.IsStreaming.get -> bool +[SER004]RESPite.Messages.RespReader.MoveNext() -> void +[SER004]RESPite.Messages.RespReader.MoveNext(RESPite.Messages.RespPrefix prefix) -> void +[SER004]RESPite.Messages.RespReader.MoveNext(RESPite.Messages.RespAttributeReader! respAttributeReader, ref T attributes) -> void +[SER004]RESPite.Messages.RespReader.MoveNext(RESPite.Messages.RespPrefix prefix, RESPite.Messages.RespAttributeReader! respAttributeReader, ref T attributes) -> void +[SER004]RESPite.Messages.RespReader.MoveNextAggregate() -> void +[SER004]RESPite.Messages.RespReader.MoveNextScalar() -> void +[SER004]RESPite.Messages.RespReader.Prefix.get -> RESPite.Messages.RespPrefix +[SER004]RESPite.Messages.RespReader.Projection +[SER004]RESPite.Messages.RespReader.ProtocolBytesRemaining.get -> long +[SER004]RESPite.Messages.RespReader.ReadArray(RESPite.Messages.RespReader.Projection! projection, bool scalar = false) -> TResult[]? +[SER004]RESPite.Messages.RespReader.ReadBoolean() -> bool +[SER004]RESPite.Messages.RespReader.ReadByteArray() -> byte[]? +[SER004]RESPite.Messages.RespReader.ReadDecimal() -> decimal +[SER004]RESPite.Messages.RespReader.ReadDouble() -> double +[SER004]RESPite.Messages.RespReader.ReadEnum(T unknownValue = default(T)) -> T +[SER004]RESPite.Messages.RespReader.ReadInt32() -> int +[SER004]RESPite.Messages.RespReader.ReadInt64() -> long +[SER004]RESPite.Messages.RespReader.ReadPairArray(RESPite.Messages.RespReader.Projection! first, RESPite.Messages.RespReader.Projection! second, System.Func! combine, bool scalar = true) -> TResult[]? +[SER004]RESPite.Messages.RespReader.ReadPastArray(RESPite.Messages.RespReader.Projection! projection, bool scalar = false) -> TResult[]? +[SER004]RESPite.Messages.RespReader.ReadString() -> string? +[SER004]RESPite.Messages.RespReader.ReadString(out string! prefix) -> string? +[SER004]RESPite.Messages.RespReader.RespReader() -> void +[SER004]RESPite.Messages.RespReader.RespReader(scoped in System.Buffers.ReadOnlySequence value) -> void +[SER004]RESPite.Messages.RespReader.RespReader(System.ReadOnlySpan value) -> void +[SER004]RESPite.Messages.RespReader.ScalarChunks() -> RESPite.Messages.RespReader.ScalarEnumerator +[SER004]RESPite.Messages.RespReader.ScalarEnumerator +[SER004]RESPite.Messages.RespReader.ScalarEnumerator.Current.get -> System.ReadOnlySpan +[SER004]RESPite.Messages.RespReader.ScalarEnumerator.CurrentLength.get -> int +[SER004]RESPite.Messages.RespReader.ScalarEnumerator.GetEnumerator() -> RESPite.Messages.RespReader.ScalarEnumerator +[SER004]RESPite.Messages.RespReader.ScalarEnumerator.MoveNext() -> bool +[SER004]RESPite.Messages.RespReader.ScalarEnumerator.MovePast(out RESPite.Messages.RespReader reader) -> void +[SER004]RESPite.Messages.RespReader.ScalarEnumerator.ScalarEnumerator() -> void +[SER004]RESPite.Messages.RespReader.ScalarEnumerator.ScalarEnumerator(scoped in RESPite.Messages.RespReader reader) -> void +[SER004]RESPite.Messages.RespReader.ScalarIsEmpty() -> bool +[SER004]RESPite.Messages.RespReader.ScalarLength() -> int +[SER004]RESPite.Messages.RespReader.ScalarLengthIs(int count) -> bool +[SER004]RESPite.Messages.RespReader.ScalarLongLength() -> long +[SER004]RESPite.Messages.RespReader.SkipChildren() -> void +[SER004]RESPite.Messages.RespReader.StartsWith(System.ReadOnlySpan value) -> bool +[SER004]RESPite.Messages.RespReader.TryGetSpan(out System.ReadOnlySpan value) -> bool +[SER004]RESPite.Messages.RespReader.TryMoveNext() -> bool +[SER004]RESPite.Messages.RespReader.TryMoveNext(bool checkError) -> bool +[SER004]RESPite.Messages.RespReader.TryMoveNext(RESPite.Messages.RespPrefix prefix) -> bool +[SER004]RESPite.Messages.RespReader.TryMoveNext(RESPite.Messages.RespAttributeReader! respAttributeReader, ref T attributes) -> bool +[SER004]RESPite.Messages.RespReader.TryReadDouble(out double value, bool allowTokens = true) -> bool +[SER004]RESPite.Messages.RespReader.TryReadInt32(out int value) -> bool +[SER004]RESPite.Messages.RespReader.TryReadInt64(out long value) -> bool +[SER004]RESPite.Messages.RespReader.TryReadNext() -> bool +[SER004]RESPite.Messages.RespScanState +[SER004]RESPite.Messages.RespScanState.IsComplete.get -> bool +[SER004]RESPite.Messages.RespScanState.Prefix.get -> RESPite.Messages.RespPrefix +[SER004]RESPite.Messages.RespScanState.RespScanState() -> void +[SER004]RESPite.Messages.RespScanState.TotalBytes.get -> long +[SER004]RESPite.Messages.RespScanState.TryRead(in System.Buffers.ReadOnlySequence value, out long bytesRead) -> bool +[SER004]RESPite.Messages.RespScanState.TryRead(ref RESPite.Messages.RespReader reader, out long bytesRead) -> bool +[SER004]RESPite.Messages.RespScanState.TryRead(System.ReadOnlySpan value, out int bytesRead) -> bool +[SER004]RESPite.RespException +[SER004]RESPite.RespException.RespException(string! message) -> void +[SER004]static RESPite.Buffers.CycleBuffer.Create(System.Buffers.MemoryPool? pool = null, int pageSize = 8192, RESPite.Buffers.ICycleBufferCallback? callback = null) -> RESPite.Buffers.CycleBuffer +[SER004]static RESPite.Messages.RespFrameScanner.Default.get -> RESPite.Messages.RespFrameScanner! +[SER004]static RESPite.Messages.RespFrameScanner.Subscription.get -> RESPite.Messages.RespFrameScanner! +[SER004]virtual RESPite.Messages.RespAttributeReader.Read(ref RESPite.Messages.RespReader reader, ref T value) -> void +[SER004]virtual RESPite.Messages.RespAttributeReader.ReadKeyValuePair(scoped System.ReadOnlySpan key, ref RESPite.Messages.RespReader reader, ref T value) -> bool +[SER004]virtual RESPite.Messages.RespAttributeReader.ReadKeyValuePairs(ref RESPite.Messages.RespReader reader, ref T value) -> int +[SER004]virtual RESPite.Messages.RespReader.Projection.Invoke(ref RESPite.Messages.RespReader value) -> T +[SER004]virtual RESPite.Messages.RespReader.Projection.Invoke(ref TState state, ref RESPite.Messages.RespReader value) -> TResult +[SER004]virtual RESPite.Messages.RespReader.ScalarParser.Invoke(scoped System.ReadOnlySpan value, out TValue result) -> bool +[SER004]RESPite.Messages.RespReader.Serialize() -> byte[]! \ No newline at end of file diff --git a/src/RESPite/PublicAPI/net6.0/PublicAPI.Shipped.txt b/src/RESPite/PublicAPI/net6.0/PublicAPI.Shipped.txt new file mode 100644 index 000000000..ab058de62 --- /dev/null +++ b/src/RESPite/PublicAPI/net6.0/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/RESPite/PublicAPI/net6.0/PublicAPI.Unshipped.txt b/src/RESPite/PublicAPI/net6.0/PublicAPI.Unshipped.txt new file mode 100644 index 000000000..ab058de62 --- /dev/null +++ b/src/RESPite/PublicAPI/net6.0/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/RESPite/PublicAPI/net8.0/PublicAPI.Shipped.txt b/src/RESPite/PublicAPI/net8.0/PublicAPI.Shipped.txt new file mode 100644 index 000000000..ab058de62 --- /dev/null +++ b/src/RESPite/PublicAPI/net8.0/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/RESPite/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/src/RESPite/PublicAPI/net8.0/PublicAPI.Unshipped.txt new file mode 100644 index 000000000..c43af2e5e --- /dev/null +++ b/src/RESPite/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -0,0 +1,3 @@ +#nullable enable +[SER004]RESPite.Messages.RespReader.ParseBytes(System.IFormatProvider? formatProvider = null) -> T +[SER004]RESPite.Messages.RespReader.ParseChars(System.IFormatProvider? formatProvider = null) -> T \ No newline at end of file diff --git a/src/RESPite/RESPite.csproj b/src/RESPite/RESPite.csproj new file mode 100644 index 000000000..f82bcdf57 --- /dev/null +++ b/src/RESPite/RESPite.csproj @@ -0,0 +1,53 @@ + + + + true + net461;netstandard2.0;net472;net6.0;net8.0;net10.0 + enable + enable + false + 2025 - $([System.DateTime]::Now.Year) Marc Gravell + readme.md + + + + + + + + + + + + + + + + + RespReader.cs + + + BlockBufferSerializer.cs + + + BlockBufferSerializer.cs + + + BlockBufferSerializer.cs + + + + + + + + + + + + + + + + diff --git a/src/RESPite/RespException.cs b/src/RESPite/RespException.cs new file mode 100644 index 000000000..6b5fd7c72 --- /dev/null +++ b/src/RESPite/RespException.cs @@ -0,0 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + +namespace RESPite; + +/// +/// Represents a RESP error message. +/// +[Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)] +public sealed class RespException(string message) : Exception(message) +{ +} diff --git a/src/RESPite/Shared/AsciiHash.Comparers.cs b/src/RESPite/Shared/AsciiHash.Comparers.cs new file mode 100644 index 000000000..7b69a15a4 --- /dev/null +++ b/src/RESPite/Shared/AsciiHash.Comparers.cs @@ -0,0 +1,37 @@ +namespace RESPite; + +public readonly partial struct AsciiHash +{ + public static IEqualityComparer CaseSensitiveEqualityComparer => CaseSensitiveComparer.Instance; + public static IEqualityComparer CaseInsensitiveEqualityComparer => CaseInsensitiveComparer.Instance; + + private sealed class CaseSensitiveComparer : IEqualityComparer + { + private CaseSensitiveComparer() { } + public static readonly CaseSensitiveComparer Instance = new(); + + public bool Equals(AsciiHash x, AsciiHash y) + { + var len = x.Length; + return (len == y.Length & x._hashCS == y._hashCS) + && (len <= MaxBytesHashed || x.Span.SequenceEqual(y.Span)); + } + + public int GetHashCode(AsciiHash obj) => obj._hashCS.GetHashCode(); + } + + private sealed class CaseInsensitiveComparer : IEqualityComparer + { + private CaseInsensitiveComparer() { } + public static readonly CaseInsensitiveComparer Instance = new(); + + public bool Equals(AsciiHash x, AsciiHash y) + { + var len = x.Length; + return (len == y.Length & x._hashUC == y._hashUC) + && (len <= MaxBytesHashed || SequenceEqualsCI(x.Span, y.Span)); + } + + public int GetHashCode(AsciiHash obj) => obj._hashUC.GetHashCode(); + } +} diff --git a/src/RESPite/Shared/AsciiHash.Instance.cs b/src/RESPite/Shared/AsciiHash.Instance.cs new file mode 100644 index 000000000..53db4ff27 --- /dev/null +++ b/src/RESPite/Shared/AsciiHash.Instance.cs @@ -0,0 +1,73 @@ +using System.Buffers.Binary; +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace RESPite; + +public readonly partial struct AsciiHash : IEquatable +{ + // ReSharper disable InconsistentNaming + private readonly long _hashCS, _hashUC; + // ReSharper restore InconsistentNaming + private readonly int _index, _length; + private readonly byte[] _arr; + + public int Length => _length; + + /// + /// The optimal buffer length (with padding) to use for this value. + /// + public int BufferLength => (Length + 1 + 7) & ~7; // an extra byte, then round up to word-size + + public ReadOnlySpan Span => new(_arr ?? [], _index, _length); + public bool IsEmpty => Length == 0; + + public AsciiHash(ReadOnlySpan value) : this(value.ToArray(), 0, value.Length) { } + public AsciiHash(string? value) : this(value is null ? [] : Encoding.ASCII.GetBytes(value)) { } + + /// + public override int GetHashCode() => _hashCS.GetHashCode(); + + /// + public override string ToString() => _length == 0 ? "" : Encoding.ASCII.GetString(_arr, _index, _length); + + /// + public override bool Equals(object? other) => other is AsciiHash hash && Equals(hash); + + /// + public bool Equals(in AsciiHash other) + { + return (_length == other.Length & _hashCS == other._hashCS) + && (_length <= MaxBytesHashed || Span.SequenceEqual(other.Span)); + } + + bool IEquatable.Equals(AsciiHash other) => Equals(other); + + public AsciiHash(byte[] arr) : this(arr, 0, -1) { } + + public AsciiHash(byte[] arr, int index, int length) + { + _arr = arr ?? []; + _index = index; + _length = length < 0 ? (_arr.Length - index) : length; + + var span = new ReadOnlySpan(_arr, _index, _length); + Hash(span, out _hashCS, out _hashUC); + } + + public bool IsCS(ReadOnlySpan value) + { + var cs = HashCS(value); + var len = _length; + if (cs != _hashCS | value.Length != len) return false; + return len <= MaxBytesHashed || Span.SequenceEqual(value); + } + + public bool IsCI(ReadOnlySpan value) + { + var uc = HashUC(value); + var len = _length; + if (uc != _hashUC | value.Length != len) return false; + return len <= MaxBytesHashed || SequenceEqualsCI(Span, value); + } +} diff --git a/src/RESPite/Shared/AsciiHash.Public.cs b/src/RESPite/Shared/AsciiHash.Public.cs new file mode 100644 index 000000000..dd31cb415 --- /dev/null +++ b/src/RESPite/Shared/AsciiHash.Public.cs @@ -0,0 +1,10 @@ +namespace RESPite; + +// in the shared file, these are declared without accessibility modifiers +public sealed partial class AsciiHashAttribute +{ +} + +public readonly partial struct AsciiHash +{ +} diff --git a/src/RESPite/Shared/AsciiHash.cs b/src/RESPite/Shared/AsciiHash.cs new file mode 100644 index 000000000..aea0ab268 --- /dev/null +++ b/src/RESPite/Shared/AsciiHash.cs @@ -0,0 +1,294 @@ +using System; +using System.Buffers.Binary; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace RESPite; + +#pragma warning disable SA1205 // deliberately omit accessibility - see AsciiHash.Public.cs + +/// +/// This type is intended to provide fast hashing functions for small ASCII strings, for example well-known +/// RESP literals that are usually identifiable by their length and initial bytes; it is not intended +/// for general purpose hashing, and the behavior is undefined for non-ASCII literals. +/// All matches must also perform a sequence equality check. +/// +/// See HastHashGenerator.md for more information and intended usage. +[AttributeUsage( + AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Field | AttributeTargets.Enum, + AllowMultiple = false, + Inherited = false)] +[Conditional("DEBUG")] // evaporate in release +[Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)] +sealed partial class AsciiHashAttribute(string token = "") : Attribute +{ + /// + /// The token expected when parsing data, if different from the implied value. The implied + /// value is the name, replacing underscores for hyphens, so: 'a_b' becomes 'a-b'. + /// + public string Token => token; + + /// + /// Indicates whether a parse operation is case-sensitive. Not used in other contexts. + /// + public bool CaseSensitive { get; set; } = true; +} + +// note: instance members are in AsciiHash.Instance.cs. +[Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)] +readonly partial struct AsciiHash +{ + /// + /// In-place ASCII upper-case conversion. + /// + public static void ToUpper(Span span) + { + foreach (ref var b in span) + { + if (b >= 'a' && b <= 'z') + b = (byte)(b & ~0x20); + } + } + + /// + /// In-place ASCII lower-case conversion. + /// + public static void ToLower(Span span) + { + foreach (ref var b in span) + { + if (b >= 'a' && b <= 'z') + b |= (byte)(b & ~0x20); + } + } + + internal const int MaxBytesHashed = sizeof(long); + + public static bool EqualsCS(ReadOnlySpan first, ReadOnlySpan second) + { + var len = first.Length; + if (len != second.Length) return false; + // for very short values, the CS hash performs CS equality + return len <= MaxBytesHashed ? HashCS(first) == HashCS(second) : first.SequenceEqual(second); + } + + public static bool SequenceEqualsCS(ReadOnlySpan first, ReadOnlySpan second) + => first.SequenceEqual(second); + + public static bool EqualsCI(ReadOnlySpan first, ReadOnlySpan second) + { + var len = first.Length; + if (len != second.Length) return false; + // for very short values, the UC hash performs CI equality + return len <= MaxBytesHashed ? HashUC(first) == HashUC(second) : SequenceEqualsCI(first, second); + } + + public static unsafe bool SequenceEqualsCI(ReadOnlySpan first, ReadOnlySpan second) + { + var len = first.Length; + if (len != second.Length) return false; + + // OK, don't be clever (SIMD, etc); the purpose of FashHash is to compare RESP key tokens, which are + // typically relatively short, think 3-20 bytes. That wouldn't even touch a SIMD vector, so: + // just loop (the exact thing we'd need to do *anyway* in a SIMD implementation, to mop up the non-SIMD + // trailing bytes). + fixed (byte* firstPtr = &MemoryMarshal.GetReference(first)) + { + fixed (byte* secondPtr = &MemoryMarshal.GetReference(second)) + { + const int CS_MASK = 0b0101_1111; + for (int i = 0; i < len; i++) + { + byte x = firstPtr[i]; + var xCI = x & CS_MASK; + if (xCI >= 'A' & xCI <= 'Z') + { + // alpha mismatch + if (xCI != (secondPtr[i] & CS_MASK)) return false; + } + else if (x != secondPtr[i]) + { + // non-alpha mismatch + return false; + } + } + + return true; + } + } + } + + public static bool EqualsCS(ReadOnlySpan first, ReadOnlySpan second) + { + var len = first.Length; + if (len != second.Length) return false; + // for very short values, the CS hash performs CS equality + return len <= MaxBytesHashed ? HashCS(first) == HashCS(second) : first.SequenceEqual(second); + } + + public static bool SequenceEqualsCS(ReadOnlySpan first, ReadOnlySpan second) + => first.SequenceEqual(second); + + public static bool EqualsCI(ReadOnlySpan first, ReadOnlySpan second) + { + var len = first.Length; + if (len != second.Length) return false; + // for very short values, the CS hash performs CS equality; check that first + return len <= MaxBytesHashed ? HashUC(first) == HashUC(second) : SequenceEqualsCI(first, second); + } + + public static unsafe bool SequenceEqualsCI(ReadOnlySpan first, ReadOnlySpan second) + { + var len = first.Length; + if (len != second.Length) return false; + + // OK, don't be clever (SIMD, etc); the purpose of FashHash is to compare RESP key tokens, which are + // typically relatively short, think 3-20 bytes. That wouldn't even touch a SIMD vector, so: + // just loop (the exact thing we'd need to do *anyway* in a SIMD implementation, to mop up the non-SIMD + // trailing bytes). + fixed (char* firstPtr = &MemoryMarshal.GetReference(first)) + { + fixed (char* secondPtr = &MemoryMarshal.GetReference(second)) + { + const int CS_MASK = 0b0101_1111; + for (int i = 0; i < len; i++) + { + int x = (byte)firstPtr[i]; + var xCI = x & CS_MASK; + if (xCI >= 'A' & xCI <= 'Z') + { + // alpha mismatch + if (xCI != (secondPtr[i] & CS_MASK)) return false; + } + else if (x != (byte)secondPtr[i]) + { + // non-alpha mismatch + return false; + } + } + + return true; + } + } + } + + public static void Hash(scoped ReadOnlySpan value, out long cs, out long uc) + { + cs = HashCS(value); + uc = ToUC(cs); + } + + public static void Hash(scoped ReadOnlySpan value, out long cs, out long uc) + { + cs = HashCS(value); + uc = ToUC(cs); + } + + public static long HashUC(scoped ReadOnlySpan value) => ToUC(HashCS(value)); + + public static long HashUC(scoped ReadOnlySpan value) => ToUC(HashCS(value)); + + internal static long ToUC(long hashCS) + { + const long LC_MASK = 0x2020_2020_2020_2020; + // check whether there are any possible lower-case letters; + // this would be anything with the 0x20 bit set + if ((hashCS & LC_MASK) == 0) return hashCS; + + // Something looks possibly lower-case; we can't just mask it off, + // because there are other non-alpha characters in that range. +#if NET || NETSTANDARD2_1_OR_GREATER + ToUpper(MemoryMarshal.CreateSpan(ref Unsafe.As(ref hashCS), sizeof(long))); + return hashCS; +#else + Span buffer = stackalloc byte[8]; + BinaryPrimitives.WriteInt64LittleEndian(buffer, hashCS); + ToUpper(buffer); + return BinaryPrimitives.ReadInt64LittleEndian(buffer); +#endif + } + + public static long HashCS(scoped ReadOnlySpan value) + { + // at least 8? we can blit + if ((value.Length >> 3) != 0) return BinaryPrimitives.ReadInt64LittleEndian(value); + + // small (<7); manual loop + // note: profiling with unsafe code to pick out elements: much slower + // note: profiling with overstamping a local: 3x slower + ulong tally = 0; + for (int i = 0; i < value.Length; i++) + { + tally |= ((ulong)value[i]) << (i << 3); + } + return (long)tally; + } + + public static long HashCS(scoped ReadOnlySpan value) + { + // note: BDN profiling with Vector64.Narrow showed no benefit + if ((value.Length >> 3) != 0) + { + // slice if necessary, so we can use bounds-elided foreach + if (value.Length != 8) value = value.Slice(0, 8); + } + ulong tally = 0; + for (int i = 0; i < value.Length; i++) + { + tally |= ((ulong)value[i]) << (i << 3); + } + return (long)tally; + } + + public static void HashCS(scoped ReadOnlySpan value, out long cs0, out long cs1) + { + cs0 = HashCS(value); + cs1 = value.Length > MaxBytesHashed ? HashCS(value.Slice(start: MaxBytesHashed)) : 0; + } + + public static void HashCS(scoped ReadOnlySpan value, out long cs0, out long cs1) + { + cs0 = HashCS(value); + cs1 = value.Length > MaxBytesHashed ? HashCS(value.Slice(start: MaxBytesHashed)) : 0; + } + + public static void HashUC(scoped ReadOnlySpan value, out long cs0, out long cs1) + { + cs0 = HashUC(value); + cs1 = value.Length > MaxBytesHashed ? HashUC(value.Slice(start: MaxBytesHashed)) : 0; + } + + public static void HashUC(scoped ReadOnlySpan value, out long cs0, out long cs1) + { + cs0 = HashUC(value); + cs1 = value.Length > MaxBytesHashed ? HashUC(value.Slice(start: MaxBytesHashed)) : 0; + } + + public static void Hash(scoped ReadOnlySpan value, out long cs0, out long uc0, out long cs1, out long uc1) + { + Hash(value, out cs0, out uc0); + if (value.Length > MaxBytesHashed) + { + Hash(value.Slice(start: MaxBytesHashed), out cs1, out uc1); + } + else + { + cs1 = uc1 = 0; + } + } + + public static void Hash(scoped ReadOnlySpan value, out long cs0, out long uc0, out long cs1, out long uc1) + { + Hash(value, out cs0, out uc0); + if (value.Length > MaxBytesHashed) + { + Hash(value.Slice(start: MaxBytesHashed), out cs1, out uc1); + } + else + { + cs1 = uc1 = 0; + } + } +} diff --git a/src/StackExchange.Redis/Experiments.cs b/src/RESPite/Shared/Experiments.cs similarity index 86% rename from src/StackExchange.Redis/Experiments.cs rename to src/RESPite/Shared/Experiments.cs index 547838873..b4b9fcee1 100644 --- a/src/StackExchange.Redis/Experiments.cs +++ b/src/RESPite/Shared/Experiments.cs @@ -1,6 +1,4 @@ -using System.Diagnostics.CodeAnalysis; - -namespace StackExchange.Redis +namespace RESPite { // example usage: // [Experimental(Experiments.SomeFeature, UrlFormat = Experiments.UrlFormat)] @@ -9,11 +7,13 @@ internal static class Experiments { public const string UrlFormat = "https://stackexchange.github.io/StackExchange.Redis/exp/"; + // ReSharper disable InconsistentNaming public const string VectorSets = "SER001"; - // ReSharper disable once InconsistentNaming public const string Server_8_4 = "SER002"; - // ReSharper disable once InconsistentNaming public const string Server_8_6 = "SER003"; + public const string Respite = "SER004"; + public const string UnitTesting = "SER005"; + // ReSharper restore InconsistentNaming } } diff --git a/src/RESPite/Shared/FrameworkShims.Encoding.cs b/src/RESPite/Shared/FrameworkShims.Encoding.cs new file mode 100644 index 000000000..92d3dab7e --- /dev/null +++ b/src/RESPite/Shared/FrameworkShims.Encoding.cs @@ -0,0 +1,50 @@ +#if !(NETCOREAPP || NETSTANDARD2_1_OR_GREATER) +// ReSharper disable once CheckNamespace +namespace System.Text +{ + internal static class EncodingExtensions + { + public static unsafe int GetBytes(this Encoding encoding, ReadOnlySpan source, Span destination) + { + if (source.IsEmpty) return 0; + fixed (byte* bPtr = destination) + { + fixed (char* cPtr = source) + { + return encoding.GetBytes(cPtr, source.Length, bPtr, destination.Length); + } + } + } + + public static unsafe int GetChars(this Encoding encoding, ReadOnlySpan source, Span destination) + { + if (source.IsEmpty) return 0; + fixed (byte* bPtr = source) + { + fixed (char* cPtr = destination) + { + return encoding.GetChars(bPtr, source.Length, cPtr, destination.Length); + } + } + } + + public static unsafe int GetCharCount(this Encoding encoding, ReadOnlySpan source) + { + if (source.IsEmpty) return 0; + fixed (byte* bPtr = source) + { + return encoding.GetCharCount(bPtr, source.Length); + } + } + + public static unsafe string GetString(this Encoding encoding, ReadOnlySpan source) + { + if (source.IsEmpty) return ""; + fixed (byte* bPtr = source) + { + return encoding.GetString(bPtr, source.Length); + } + } + } +} +#endif diff --git a/src/RESPite/Shared/FrameworkShims.Stream.cs b/src/RESPite/Shared/FrameworkShims.Stream.cs new file mode 100644 index 000000000..56823abc4 --- /dev/null +++ b/src/RESPite/Shared/FrameworkShims.Stream.cs @@ -0,0 +1,107 @@ +using System.Buffers; +using System.Runtime.InteropServices; + +#if !(NETCOREAPP || NETSTANDARD2_1_OR_GREATER) +// ReSharper disable once CheckNamespace +namespace System.IO +{ + internal static class StreamExtensions + { + public static void Write(this Stream stream, ReadOnlyMemory value) + { + if (MemoryMarshal.TryGetArray(value, out var segment)) + { + stream.Write(segment.Array!, segment.Offset, segment.Count); + } + else + { + var leased = ArrayPool.Shared.Rent(value.Length); + value.CopyTo(leased); + stream.Write(leased, 0, value.Length); + ArrayPool.Shared.Return(leased); // on success only + } + } + + public static int Read(this Stream stream, Memory value) + { + if (MemoryMarshal.TryGetArray(value, out var segment)) + { + return stream.Read(segment.Array!, segment.Offset, segment.Count); + } + else + { + var leased = ArrayPool.Shared.Rent(value.Length); + int bytes = stream.Read(leased, 0, value.Length); + if (bytes > 0) + { + leased.AsSpan(0, bytes).CopyTo(value.Span); + } + ArrayPool.Shared.Return(leased); // on success only + return bytes; + } + } + + public static ValueTask ReadAsync(this Stream stream, Memory value, CancellationToken cancellationToken) + { + if (MemoryMarshal.TryGetArray(value, out var segment)) + { + return new(stream.ReadAsync(segment.Array!, segment.Offset, segment.Count, cancellationToken)); + } + else + { + var leased = ArrayPool.Shared.Rent(value.Length); + var pending = stream.ReadAsync(leased, 0, value.Length, cancellationToken); + if (!pending.IsCompleted) + { + return Awaited(pending, value, leased); + } + + var bytes = pending.GetAwaiter().GetResult(); + if (bytes > 0) + { + leased.AsSpan(0, bytes).CopyTo(value.Span); + } + ArrayPool.Shared.Return(leased); // on success only + return new(bytes); + + static async ValueTask Awaited(Task pending, Memory value, byte[] leased) + { + var bytes = await pending.ConfigureAwait(false); + if (bytes > 0) + { + leased.AsSpan(0, bytes).CopyTo(value.Span); + } + ArrayPool.Shared.Return(leased); // on success only + return bytes; + } + } + } + + public static ValueTask WriteAsync(this Stream stream, ReadOnlyMemory value, CancellationToken cancellationToken) + { + if (MemoryMarshal.TryGetArray(value, out var segment)) + { + return new(stream.WriteAsync(segment.Array!, segment.Offset, segment.Count, cancellationToken)); + } + else + { + var leased = ArrayPool.Shared.Rent(value.Length); + value.CopyTo(leased); + var pending = stream.WriteAsync(leased, 0, value.Length, cancellationToken); + if (!pending.IsCompleted) + { + return Awaited(pending, leased); + } + pending.GetAwaiter().GetResult(); + ArrayPool.Shared.Return(leased); // on success only + return default; + } + static async ValueTask Awaited(Task pending, byte[] leased) + { + await pending.ConfigureAwait(false); + ArrayPool.Shared.Return(leased); // on success only + } + } + } +} +#endif diff --git a/src/RESPite/Shared/FrameworkShims.cs b/src/RESPite/Shared/FrameworkShims.cs new file mode 100644 index 000000000..0f7aa641c --- /dev/null +++ b/src/RESPite/Shared/FrameworkShims.cs @@ -0,0 +1,15 @@ +#pragma warning disable SA1403 // single namespace + +#if !NET9_0_OR_GREATER +namespace System.Runtime.CompilerServices +{ + // see https://learn.microsoft.com/dotnet/api/system.runtime.compilerservices.overloadresolutionpriorityattribute + [AttributeUsage(AttributeTargets.Constructor | AttributeTargets.Method | AttributeTargets.Property, Inherited = false)] + internal sealed class OverloadResolutionPriorityAttribute(int priority) : Attribute + { + public int Priority => priority; + } +} +#endif + +#pragma warning restore SA1403 diff --git a/src/RESPite/Shared/NullableHacks.cs b/src/RESPite/Shared/NullableHacks.cs new file mode 100644 index 000000000..5f8969c73 --- /dev/null +++ b/src/RESPite/Shared/NullableHacks.cs @@ -0,0 +1,148 @@ +// https://github.com/dotnet/runtime/blob/527f9ae88a0ee216b44d556f9bdc84037fe0ebda/src/libraries/System.Private.CoreLib/src/System/Diagnostics/CodeAnalysis/NullableAttributes.cs + +#pragma warning disable +#define INTERNAL_NULLABLE_ATTRIBUTES + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Diagnostics.CodeAnalysis +{ +#if NETSTANDARD2_0 || NETCOREAPP2_0 || NETCOREAPP2_1 || NETCOREAPP2_2 || NET45 || NET451 || NET452 || NET46 || NET461 || NET462 || NET47 || NET471 || NET472 || NET48 + /// Specifies that null is allowed as an input even if the corresponding type disallows it. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] + internal sealed class AllowNullAttribute : Attribute { } + + /// Specifies that null is disallowed as an input even if the corresponding type allows it. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] + internal sealed class DisallowNullAttribute : Attribute { } + + /// Specifies that an output may be null even if the corresponding type disallows it. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)] + internal sealed class MaybeNullAttribute : Attribute { } + + /// Specifies that an output will not be null even if the corresponding type allows it. Specifies that an input argument was not null when the call returns. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)] + internal sealed class NotNullAttribute : Attribute { } + + /// Specifies that when a method returns , the parameter may be null even if the corresponding type disallows it. + [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] + internal sealed class MaybeNullWhenAttribute : Attribute + { + /// Initializes the attribute with the specified return value condition. + /// + /// The return value condition. If the method returns this value, the associated parameter may be null. + /// + public MaybeNullWhenAttribute(bool returnValue) => ReturnValue = returnValue; + + /// Gets the return value condition. + public bool ReturnValue { get; } + } + + /// Specifies that when a method returns , the parameter will not be null even if the corresponding type allows it. + [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] + internal sealed class NotNullWhenAttribute : Attribute + { + /// Initializes the attribute with the specified return value condition. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + public NotNullWhenAttribute(bool returnValue) => ReturnValue = returnValue; + + /// Gets the return value condition. + public bool ReturnValue { get; } + } + + /// Specifies that the output will be non-null if the named parameter is non-null. + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, AllowMultiple = true, Inherited = false)] + internal sealed class NotNullIfNotNullAttribute : Attribute + { + /// Initializes the attribute with the associated parameter name. + /// + /// The associated parameter name. The output will be non-null if the argument to the parameter specified is non-null. + /// + public NotNullIfNotNullAttribute(string parameterName) => ParameterName = parameterName; + + /// Gets the associated parameter name. + public string ParameterName { get; } + } + + /// Applied to a method that will never return under any circumstance. + [AttributeUsage(AttributeTargets.Method, Inherited = false)] + internal sealed class DoesNotReturnAttribute : Attribute { } + + /// Specifies that the method will not return if the associated Boolean parameter is passed the specified value. + [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] + internal sealed class DoesNotReturnIfAttribute : Attribute + { + /// Initializes the attribute with the specified parameter value. + /// + /// The condition parameter value. Code after the method will be considered unreachable by diagnostics if the argument to + /// the associated parameter matches this value. + /// + public DoesNotReturnIfAttribute(bool parameterValue) => ParameterValue = parameterValue; + + /// Gets the condition parameter value. + public bool ParameterValue { get; } + } +#endif + +#if NETSTANDARD2_0 || NETCOREAPP2_0 || NETCOREAPP2_1 || NETCOREAPP2_2 || NETCOREAPP3_0 || NETCOREAPP3_1 || NET45 || NET451 || NET452 || NET46 || NET461 || NET462 || NET47 || NET471 || NET472 || NET48 + /// Specifies that the method or property will ensure that the listed field and property members have not-null values. + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] + internal sealed class MemberNotNullAttribute : Attribute + { + /// Initializes the attribute with a field or property member. + /// + /// The field or property member that is promised to be not-null. + /// + public MemberNotNullAttribute(string member) => Members = new[] { member }; + + /// Initializes the attribute with the list of field and property members. + /// + /// The list of field and property members that are promised to be not-null. + /// + public MemberNotNullAttribute(params string[] members) => Members = members; + + /// Gets field or property member names. + public string[] Members { get; } + } + + /// Specifies that the method or property will ensure that the listed field and property members have not-null values when returning with the specified return value condition. + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] + internal sealed class MemberNotNullWhenAttribute : Attribute + { + /// Initializes the attribute with the specified return value condition and a field or property member. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + /// + /// The field or property member that is promised to be not-null. + /// + public MemberNotNullWhenAttribute(bool returnValue, string member) + { + ReturnValue = returnValue; + Members = new[] { member }; + } + + /// Initializes the attribute with the specified return value condition and list of field and property members. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + /// + /// The list of field and property members that are promised to be not-null. + /// + public MemberNotNullWhenAttribute(bool returnValue, params string[] members) + { + ReturnValue = returnValue; + Members = members; + } + + /// Gets the return value condition. + public bool ReturnValue { get; } + + /// Gets field or property member names. + public string[] Members { get; } + } +#endif +} diff --git a/src/RESPite/readme.md b/src/RESPite/readme.md new file mode 100644 index 000000000..034cae8d3 --- /dev/null +++ b/src/RESPite/readme.md @@ -0,0 +1,6 @@ +# RESPite + +RESPite is a high-performance low-level RESP (Redis, etc) library, used as the IO core for +StackExchange.Redis v3+. It is also available for direct use from other places! + +For now: you probably shouldn't be using this. \ No newline at end of file diff --git a/src/StackExchange.Redis/APITypes/StreamInfo.cs b/src/StackExchange.Redis/APITypes/StreamInfo.cs index e37df5add..1de0526ec 100644 --- a/src/StackExchange.Redis/APITypes/StreamInfo.cs +++ b/src/StackExchange.Redis/APITypes/StreamInfo.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using RESPite; namespace StackExchange.Redis; diff --git a/src/StackExchange.Redis/Enums/RedisCommand.cs b/src/StackExchange.Redis/Enums/RedisCommand.cs index 2a4d1180f..2a1c7695e 100644 --- a/src/StackExchange.Redis/Enums/RedisCommand.cs +++ b/src/StackExchange.Redis/Enums/RedisCommand.cs @@ -1,7 +1,9 @@ using System; +using RESPite; namespace StackExchange.Redis; +// ReSharper disable InconsistentNaming internal enum RedisCommand { NONE, // must be first for "zero reasons" @@ -280,6 +282,16 @@ internal enum RedisCommand UNKNOWN, } +internal static partial class RedisCommandMetadata +{ + [AsciiHash(CaseSensitive = false)] + public static partial bool TryParseCI(ReadOnlySpan command, out RedisCommand value); + + [AsciiHash(CaseSensitive = false)] + public static partial bool TryParseCI(ReadOnlySpan command, out RedisCommand value); +} + +// ReSharper restore InconsistentNaming internal static class RedisCommandExtensions { /// diff --git a/src/StackExchange.Redis/FastHash.cs b/src/StackExchange.Redis/FastHash.cs deleted file mode 100644 index 49eb01b31..000000000 --- a/src/StackExchange.Redis/FastHash.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System; -using System.Buffers; -using System.Buffers.Binary; -using System.Diagnostics; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -namespace StackExchange.Redis; - -/// -/// This type is intended to provide fast hashing functions for small strings, for example well-known -/// RESP literals that are usually identifiable by their length and initial bytes; it is not intended -/// for general purpose hashing. All matches must also perform a sequence equality check. -/// -/// See HastHashGenerator.md for more information and intended usage. -[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] -[Conditional("DEBUG")] // evaporate in release -internal sealed class FastHashAttribute(string token = "") : Attribute -{ - public string Token => token; -} - -internal static class FastHash -{ - /* not sure we need this, but: retain for reference - - // Perform case-insensitive hash by masking (X and x differ by only 1 bit); this halves - // our entropy, but is still useful when case doesn't matter. - private const long CaseMask = ~0x2020202020202020; - - public static long Hash64CI(this ReadOnlySequence value) - => value.Hash64() & CaseMask; - public static long Hash64CI(this scoped ReadOnlySpan value) - => value.Hash64() & CaseMask; -*/ - - public static long Hash64(this ReadOnlySequence value) - { -#if NETCOREAPP3_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER - var first = value.FirstSpan; -#else - var first = value.First.Span; -#endif - return first.Length >= sizeof(long) || value.IsSingleSegment - ? first.Hash64() : SlowHash64(value); - - static long SlowHash64(ReadOnlySequence value) - { - Span buffer = stackalloc byte[sizeof(long)]; - if (value.Length < sizeof(long)) - { - value.CopyTo(buffer); - buffer.Slice((int)value.Length).Clear(); - } - else - { - value.Slice(0, sizeof(long)).CopyTo(buffer); - } - return BitConverter.IsLittleEndian - ? Unsafe.ReadUnaligned(ref MemoryMarshal.GetReference(buffer)) - : BinaryPrimitives.ReadInt64LittleEndian(buffer); - } - } - - public static long Hash64(this scoped ReadOnlySpan value) - { - if (BitConverter.IsLittleEndian) - { - ref byte data = ref MemoryMarshal.GetReference(value); - return value.Length switch - { - 0 => 0, - 1 => data, // 0000000A - 2 => Unsafe.ReadUnaligned(ref data), // 000000BA - 3 => Unsafe.ReadUnaligned(ref data) | // 000000BA - (Unsafe.Add(ref data, 2) << 16), // 00000C00 - 4 => Unsafe.ReadUnaligned(ref data), // 0000DCBA - 5 => Unsafe.ReadUnaligned(ref data) | // 0000DCBA - ((long)Unsafe.Add(ref data, 4) << 32), // 000E0000 - 6 => Unsafe.ReadUnaligned(ref data) | // 0000DCBA - ((long)Unsafe.ReadUnaligned(ref Unsafe.Add(ref data, 4)) << 32), // 00FE0000 - 7 => Unsafe.ReadUnaligned(ref data) | // 0000DCBA - ((long)Unsafe.ReadUnaligned(ref Unsafe.Add(ref data, 4)) << 32) | // 00FE0000 - ((long)Unsafe.Add(ref data, 6) << 48), // 0G000000 - _ => Unsafe.ReadUnaligned(ref data), // HGFEDCBA - }; - } - -#pragma warning disable CS0618 // Type or member is obsolete - return Hash64Fallback(value); -#pragma warning restore CS0618 // Type or member is obsolete - } - - [Obsolete("Only exists for benchmarks (to show that we don't need to use it) and unit tests (for correctness)")] - internal static unsafe long Hash64Unsafe(scoped ReadOnlySpan value) - { - if (BitConverter.IsLittleEndian) - { - fixed (byte* ptr = &MemoryMarshal.GetReference(value)) - { - return value.Length switch - { - 0 => 0, - 1 => *ptr, // 0000000A - 2 => *(ushort*)ptr, // 000000BA - 3 => *(ushort*)ptr | // 000000BA - (ptr[2] << 16), // 00000C00 - 4 => *(int*)ptr, // 0000DCBA - 5 => (long)*(int*)ptr | // 0000DCBA - ((long)ptr[4] << 32), // 000E0000 - 6 => (long)*(int*)ptr | // 0000DCBA - ((long)*(ushort*)(ptr + 4) << 32), // 00FE0000 - 7 => (long)*(int*)ptr | // 0000DCBA - ((long)*(ushort*)(ptr + 4) << 32) | // 00FE0000 - ((long)ptr[6] << 48), // 0G000000 - _ => *(long*)ptr, // HGFEDCBA - }; - } - } - - return Hash64Fallback(value); - } - - [Obsolete("Only exists for unit tests and fallback")] - internal static long Hash64Fallback(scoped ReadOnlySpan value) - { - if (value.Length < sizeof(long)) - { - Span tmp = stackalloc byte[sizeof(long)]; - value.CopyTo(tmp); // ABC***** - tmp.Slice(value.Length).Clear(); // ABC00000 - return BinaryPrimitives.ReadInt64LittleEndian(tmp); // 00000CBA - } - - return BinaryPrimitives.ReadInt64LittleEndian(value); // HGFEDCBA - } -} diff --git a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs index a0f5b2892..71644c010 100644 --- a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs +++ b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs @@ -36,20 +36,22 @@ private HotKeysResult(in RawResult result) var iter = result.GetItems().GetEnumerator(); while (iter.MoveNext()) { - ref readonly RawResult key = ref iter.Current; + if (!iter.Current.TryParse(HotKeysFieldMetadata.TryParse, out HotKeysField field)) + field = HotKeysField.Unknown; + if (!iter.MoveNext()) break; // lies about the length! ref readonly RawResult value = ref iter.Current; - var hash = key.Payload.Hash64(); + long i64; - switch (hash) + switch (field) { - case tracking_active.Hash when tracking_active.Is(hash, key): + case HotKeysField.TrackingActive: TrackingActive = value.GetBoolean(); break; - case sample_ratio.Hash when sample_ratio.Is(hash, key) && value.TryGetInt64(out i64): + case HotKeysField.SampleRatio when value.TryGetInt64(out i64): SampleRatio = i64; break; - case selected_slots.Hash when selected_slots.Is(hash, key) & value.Resp2TypeArray is ResultType.Array: + case HotKeysField.SelectedSlots when value.Resp2TypeArray is ResultType.Array: var len = value.ItemsCount; if (len == 0) { @@ -92,55 +94,55 @@ private HotKeysResult(in RawResult result) } _selectedSlots = slots; break; - case all_commands_all_slots_us.Hash when all_commands_all_slots_us.Is(hash, key) && value.TryGetInt64(out i64): + case HotKeysField.AllCommandsAllSlotsUs when value.TryGetInt64(out i64): AllCommandsAllSlotsMicroseconds = i64; break; - case all_commands_selected_slots_us.Hash when all_commands_selected_slots_us.Is(hash, key) && value.TryGetInt64(out i64): + case HotKeysField.AllCommandsSelectedSlotsUs when value.TryGetInt64(out i64): AllCommandSelectedSlotsMicroseconds = i64; break; - case sampled_command_selected_slots_us.Hash when sampled_command_selected_slots_us.Is(hash, key) && value.TryGetInt64(out i64): - case sampled_commands_selected_slots_us.Hash when sampled_commands_selected_slots_us.Is(hash, key) && value.TryGetInt64(out i64): + case HotKeysField.SampledCommandSelectedSlotsUs when value.TryGetInt64(out i64): + case HotKeysField.SampledCommandsSelectedSlotsUs when value.TryGetInt64(out i64): SampledCommandsSelectedSlotsMicroseconds = i64; break; - case net_bytes_all_commands_all_slots.Hash when net_bytes_all_commands_all_slots.Is(hash, key) && value.TryGetInt64(out i64): + case HotKeysField.NetBytesAllCommandsAllSlots when value.TryGetInt64(out i64): AllCommandsAllSlotsNetworkBytes = i64; break; - case net_bytes_all_commands_selected_slots.Hash when net_bytes_all_commands_selected_slots.Is(hash, key) && value.TryGetInt64(out i64): + case HotKeysField.NetBytesAllCommandsSelectedSlots when value.TryGetInt64(out i64): NetworkBytesAllCommandsSelectedSlotsRaw = i64; break; - case net_bytes_sampled_commands_selected_slots.Hash when net_bytes_sampled_commands_selected_slots.Is(hash, key) && value.TryGetInt64(out i64): + case HotKeysField.NetBytesSampledCommandsSelectedSlots when value.TryGetInt64(out i64): NetworkBytesSampledCommandsSelectedSlotsRaw = i64; break; - case collection_start_time_unix_ms.Hash when collection_start_time_unix_ms.Is(hash, key) && value.TryGetInt64(out i64): + case HotKeysField.CollectionStartTimeUnixMs when value.TryGetInt64(out i64): CollectionStartTimeUnixMilliseconds = i64; break; - case collection_duration_ms.Hash when collection_duration_ms.Is(hash, key) && value.TryGetInt64(out i64): + case HotKeysField.CollectionDurationMs when value.TryGetInt64(out i64): CollectionDurationMicroseconds = i64 * 1000; // ms vs us is in question: support both, and abstract it from the caller break; - case collection_duration_us.Hash when collection_duration_us.Is(hash, key) && value.TryGetInt64(out i64): + case HotKeysField.CollectionDurationUs when value.TryGetInt64(out i64): CollectionDurationMicroseconds = i64; break; - case total_cpu_time_sys_ms.Hash when total_cpu_time_sys_ms.Is(hash, key) && value.TryGetInt64(out i64): + case HotKeysField.TotalCpuTimeSysMs when value.TryGetInt64(out i64): metrics |= HotKeysMetrics.Cpu; TotalCpuTimeSystemMicroseconds = i64 * 1000; // ms vs us is in question: support both, and abstract it from the caller break; - case total_cpu_time_sys_us.Hash when total_cpu_time_sys_us.Is(hash, key) && value.TryGetInt64(out i64): + case HotKeysField.TotalCpuTimeSysUs when value.TryGetInt64(out i64): metrics |= HotKeysMetrics.Cpu; TotalCpuTimeSystemMicroseconds = i64; break; - case total_cpu_time_user_ms.Hash when total_cpu_time_user_ms.Is(hash, key) && value.TryGetInt64(out i64): + case HotKeysField.TotalCpuTimeUserMs when value.TryGetInt64(out i64): metrics |= HotKeysMetrics.Cpu; TotalCpuTimeUserMicroseconds = i64 * 1000; // ms vs us is in question: support both, and abstract it from the caller break; - case total_cpu_time_user_us.Hash when total_cpu_time_user_us.Is(hash, key) && value.TryGetInt64(out i64): + case HotKeysField.TotalCpuTimeUserUs when value.TryGetInt64(out i64): metrics |= HotKeysMetrics.Cpu; TotalCpuTimeUserMicroseconds = i64; break; - case total_net_bytes.Hash when total_net_bytes.Is(hash, key) && value.TryGetInt64(out i64): + case HotKeysField.TotalNetBytes when value.TryGetInt64(out i64): metrics |= HotKeysMetrics.Network; TotalNetworkBytesRaw = i64; break; - case by_cpu_time_us.Hash when by_cpu_time_us.Is(hash, key) & value.Resp2TypeArray is ResultType.Array: + case HotKeysField.ByCpuTimeUs when value.Resp2TypeArray is ResultType.Array: metrics |= HotKeysMetrics.Cpu; len = value.ItemsCount / 2; if (len == 0) @@ -162,7 +164,7 @@ private HotKeysResult(in RawResult result) _cpuByKey = cpuTime; break; - case by_net_bytes.Hash when by_net_bytes.Is(hash, key) & value.Resp2TypeArray is ResultType.Array: + case HotKeysField.ByNetBytes when value.Resp2TypeArray is ResultType.Array: metrics |= HotKeysMetrics.Network; len = value.ItemsCount / 2; if (len == 0) @@ -188,30 +190,4 @@ private HotKeysResult(in RawResult result) } // while Metrics = metrics; } - -#pragma warning disable SA1134, SA1300 - // ReSharper disable InconsistentNaming - [FastHash] internal static partial class tracking_active { } - [FastHash] internal static partial class sample_ratio { } - [FastHash] internal static partial class selected_slots { } - [FastHash] internal static partial class all_commands_all_slots_us { } - [FastHash] internal static partial class all_commands_selected_slots_us { } - [FastHash] internal static partial class sampled_command_selected_slots_us { } - [FastHash] internal static partial class sampled_commands_selected_slots_us { } - [FastHash] internal static partial class net_bytes_all_commands_all_slots { } - [FastHash] internal static partial class net_bytes_all_commands_selected_slots { } - [FastHash] internal static partial class net_bytes_sampled_commands_selected_slots { } - [FastHash] internal static partial class collection_start_time_unix_ms { } - [FastHash] internal static partial class collection_duration_ms { } - [FastHash] internal static partial class collection_duration_us { } - [FastHash] internal static partial class total_cpu_time_user_ms { } - [FastHash] internal static partial class total_cpu_time_user_us { } - [FastHash] internal static partial class total_cpu_time_sys_ms { } - [FastHash] internal static partial class total_cpu_time_sys_us { } - [FastHash] internal static partial class total_net_bytes { } - [FastHash] internal static partial class by_cpu_time_us { } - [FastHash] internal static partial class by_net_bytes { } - - // ReSharper restore InconsistentNaming -#pragma warning restore SA1134, SA1300 } diff --git a/src/StackExchange.Redis/HotKeys.cs b/src/StackExchange.Redis/HotKeys.cs index 28f3ddc56..c3d71fe17 100644 --- a/src/StackExchange.Redis/HotKeys.cs +++ b/src/StackExchange.Redis/HotKeys.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; +using RESPite; namespace StackExchange.Redis; diff --git a/src/StackExchange.Redis/HotKeysField.cs b/src/StackExchange.Redis/HotKeysField.cs new file mode 100644 index 000000000..0c514c6fd --- /dev/null +++ b/src/StackExchange.Redis/HotKeysField.cs @@ -0,0 +1,145 @@ +using System; +using RESPite; + +namespace StackExchange.Redis; + +/// +/// Fields that can appear in a HOTKEYS response. +/// +internal enum HotKeysField +{ + /// + /// Unknown or unrecognized field. + /// + [AsciiHash("")] + Unknown = 0, + + /// + /// Whether tracking is active. + /// + [AsciiHash("tracking-active")] + TrackingActive, + + /// + /// Sample ratio. + /// + [AsciiHash("sample-ratio")] + SampleRatio, + + /// + /// Selected slots. + /// + [AsciiHash("selected-slots")] + SelectedSlots, + + /// + /// All commands all slots microseconds. + /// + [AsciiHash("all-commands-all-slots-us")] + AllCommandsAllSlotsUs, + + /// + /// All commands selected slots microseconds. + /// + [AsciiHash("all-commands-selected-slots-us")] + AllCommandsSelectedSlotsUs, + + /// + /// Sampled command selected slots microseconds (singular). + /// + [AsciiHash("sampled-command-selected-slots-us")] + SampledCommandSelectedSlotsUs, + + /// + /// Sampled commands selected slots microseconds (plural). + /// + [AsciiHash("sampled-commands-selected-slots-us")] + SampledCommandsSelectedSlotsUs, + + /// + /// Network bytes all commands all slots. + /// + [AsciiHash("net-bytes-all-commands-all-slots")] + NetBytesAllCommandsAllSlots, + + /// + /// Network bytes all commands selected slots. + /// + [AsciiHash("net-bytes-all-commands-selected-slots")] + NetBytesAllCommandsSelectedSlots, + + /// + /// Network bytes sampled commands selected slots. + /// + [AsciiHash("net-bytes-sampled-commands-selected-slots")] + NetBytesSampledCommandsSelectedSlots, + + /// + /// Collection start time in Unix milliseconds. + /// + [AsciiHash("collection-start-time-unix-ms")] + CollectionStartTimeUnixMs, + + /// + /// Collection duration in milliseconds. + /// + [AsciiHash("collection-duration-ms")] + CollectionDurationMs, + + /// + /// Collection duration in microseconds. + /// + [AsciiHash("collection-duration-us")] + CollectionDurationUs, + + /// + /// Total CPU time user in milliseconds. + /// + [AsciiHash("total-cpu-time-user-ms")] + TotalCpuTimeUserMs, + + /// + /// Total CPU time user in microseconds. + /// + [AsciiHash("total-cpu-time-user-us")] + TotalCpuTimeUserUs, + + /// + /// Total CPU time system in milliseconds. + /// + [AsciiHash("total-cpu-time-sys-ms")] + TotalCpuTimeSysMs, + + /// + /// Total CPU time system in microseconds. + /// + [AsciiHash("total-cpu-time-sys-us")] + TotalCpuTimeSysUs, + + /// + /// Total network bytes. + /// + [AsciiHash("total-net-bytes")] + TotalNetBytes, + + /// + /// By CPU time in microseconds. + /// + [AsciiHash("by-cpu-time-us")] + ByCpuTimeUs, + + /// + /// By network bytes. + /// + [AsciiHash("by-net-bytes")] + ByNetBytes, +} + +/// +/// Metadata and parsing methods for HotKeysField. +/// +internal static partial class HotKeysFieldMetadata +{ + [AsciiHash] + internal static partial bool TryParse(ReadOnlySpan value, out HotKeysField field); +} diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.VectorSets.cs b/src/StackExchange.Redis/Interfaces/IDatabase.VectorSets.cs index 1c163f315..60b32844e 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabase.VectorSets.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabase.VectorSets.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; +using RESPite; // ReSharper disable once CheckNamespace namespace StackExchange.Redis; diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.cs b/src/StackExchange.Redis/Interfaces/IDatabase.cs index cf2ecafac..e26154652 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabase.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs @@ -3,6 +3,7 @@ using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Net; +using RESPite; // ReSharper disable once CheckNamespace namespace StackExchange.Redis diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.VectorSets.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.VectorSets.cs index 7b8825e4c..56666ce3d 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.VectorSets.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.VectorSets.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; +using RESPite; // ReSharper disable once CheckNamespace namespace StackExchange.Redis; diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs index 029c7975e..c581470ca 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs @@ -5,6 +5,7 @@ using System.IO; using System.Net; using System.Threading.Tasks; +using RESPite; // ReSharper disable once CheckNamespace namespace StackExchange.Redis diff --git a/src/StackExchange.Redis/KeyNotification.cs b/src/StackExchange.Redis/KeyNotification.cs index 3427c4dce..08c157bc6 100644 --- a/src/StackExchange.Redis/KeyNotification.cs +++ b/src/StackExchange.Redis/KeyNotification.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Runtime.CompilerServices; using System.Text; +using RESPite; using static StackExchange.Redis.KeyNotificationChannels; namespace StackExchange.Redis; @@ -37,11 +38,11 @@ public static bool TryParse(scoped in RedisChannel channel, scoped in RedisValue { // check that the prefix is valid, i.e. "__keyspace@" or "__keyevent@" var prefix = span.Slice(0, KeySpacePrefix.Length); - var hash = prefix.Hash64(); - switch (hash) + var hashCS = AsciiHash.HashCS(prefix); + switch (hashCS) { - case KeySpacePrefix.Hash when KeySpacePrefix.Is(hash, prefix): - case KeyEventPrefix.Hash when KeyEventPrefix.Is(hash, prefix): + case KeyEventPrefix.HashCS when KeyEventPrefix.IsCS(prefix, hashCS): + case KeySpacePrefix.HashCS when KeySpacePrefix.IsCS(prefix, hashCS): // check that there is *something* non-empty after the prefix, with __: as the suffix (we don't verify *what*) if (span.Slice(KeySpacePrefix.Length).IndexOf("__:"u8) > 0) { @@ -410,25 +411,22 @@ public KeyNotificationType Type if (IsKeySpace) { // then the channel contains the key, and the payload contains the event-type - var count = _value.GetByteCount(); - if (count >= KeyNotificationTypeFastHash.MinBytes & count <= KeyNotificationTypeFastHash.MaxBytes) + if (_value.TryGetSpan(out var direct)) { - if (_value.TryGetSpan(out var direct)) - { - return KeyNotificationTypeFastHash.Parse(direct); - } - else - { - Span localCopy = stackalloc byte[KeyNotificationTypeFastHash.MaxBytes]; - return KeyNotificationTypeFastHash.Parse(localCopy.Slice(0, _value.CopyTo(localCopy))); - } + return KeyNotificationTypeMetadata.Parse(direct); } - } - if (IsKeyEvent) + if (_value.GetByteCount() <= KeyNotificationTypeMetadata.BufferBytes) + { + Span localCopy = stackalloc byte[KeyNotificationTypeMetadata.BufferBytes]; + var len = _value.CopyTo(localCopy); + return KeyNotificationTypeMetadata.Parse(localCopy.Slice(0, len)); + } + } + else if (IsKeyEvent) { // then the channel contains the event-type, and the payload contains the key - return KeyNotificationTypeFastHash.Parse(ChannelSuffix); + return KeyNotificationTypeMetadata.Parse(ChannelSuffix); } return KeyNotificationType.Unknown; } @@ -442,7 +440,7 @@ public bool IsKeySpace get { var span = _channel.Span; - return span.Length >= KeySpacePrefix.Length + MinSuffixBytes && KeySpacePrefix.Is(span.Hash64(), span.Slice(0, KeySpacePrefix.Length)); + return span.Length >= KeySpacePrefix.Length + MinSuffixBytes && KeySpacePrefix.IsCS(span.Slice(0, KeySpacePrefix.Length), AsciiHash.HashCS(span)); } } @@ -454,7 +452,7 @@ public bool IsKeyEvent get { var span = _channel.Span; - return span.Length >= KeyEventPrefix.Length + MinSuffixBytes && KeyEventPrefix.Is(span.Hash64(), span.Slice(0, KeyEventPrefix.Length)); + return span.Length >= KeyEventPrefix.Length + MinSuffixBytes && KeyEventPrefix.IsCS(span.Slice(0, KeyEventPrefix.Length), AsciiHash.HashCS(span)); } } @@ -485,12 +483,12 @@ public bool KeyStartsWith(ReadOnlySpan prefix) // intentionally leading pe internal static partial class KeyNotificationChannels { - [FastHash("__keyspace@")] + [AsciiHash("__keyspace@")] internal static partial class KeySpacePrefix { } - [FastHash("__keyevent@")] + [AsciiHash("__keyevent@")] internal static partial class KeyEventPrefix { } diff --git a/src/StackExchange.Redis/KeyNotificationType.cs b/src/StackExchange.Redis/KeyNotificationType.cs index cc4c74ef1..d45d11e47 100644 --- a/src/StackExchange.Redis/KeyNotificationType.cs +++ b/src/StackExchange.Redis/KeyNotificationType.cs @@ -1,69 +1,127 @@ -namespace StackExchange.Redis; +using RESPite; + +namespace StackExchange.Redis; /// /// The type of keyspace or keyevent notification. /// +[AsciiHash(nameof(KeyNotificationTypeMetadata))] public enum KeyNotificationType { // note: initially presented alphabetically, but: new values *must* be appended, not inserted // (to preserve values of existing elements) #pragma warning disable CS1591 // docs, redundant + [AsciiHash("")] Unknown = 0, + [AsciiHash("append")] Append = 1, + [AsciiHash("copy")] Copy = 2, + [AsciiHash("del")] Del = 3, + [AsciiHash("expire")] Expire = 4, + [AsciiHash("hdel")] HDel = 5, + [AsciiHash("hexpired")] HExpired = 6, + [AsciiHash("hincrbyfloat")] HIncrByFloat = 7, + [AsciiHash("hincrby")] HIncrBy = 8, + [AsciiHash("hpersist")] HPersist = 9, + [AsciiHash("hset")] HSet = 10, + [AsciiHash("incrbyfloat")] IncrByFloat = 11, + [AsciiHash("incrby")] IncrBy = 12, + [AsciiHash("linsert")] LInsert = 13, + [AsciiHash("lpop")] LPop = 14, + [AsciiHash("lpush")] LPush = 15, + [AsciiHash("lrem")] LRem = 16, + [AsciiHash("lset")] LSet = 17, + [AsciiHash("ltrim")] LTrim = 18, + [AsciiHash("move_from")] MoveFrom = 19, + [AsciiHash("move_to")] MoveTo = 20, + [AsciiHash("persist")] Persist = 21, + [AsciiHash("rename_from")] RenameFrom = 22, + [AsciiHash("rename_to")] RenameTo = 23, + [AsciiHash("restore")] Restore = 24, + [AsciiHash("rpop")] RPop = 25, + [AsciiHash("rpush")] RPush = 26, + [AsciiHash("sadd")] SAdd = 27, + [AsciiHash("set")] Set = 28, + [AsciiHash("setrange")] SetRange = 29, + [AsciiHash("sortstore")] SortStore = 30, + [AsciiHash("srem")] SRem = 31, + [AsciiHash("spop")] SPop = 32, + [AsciiHash("xadd")] XAdd = 33, + [AsciiHash("xdel")] XDel = 34, + [AsciiHash("xgroup-createconsumer")] XGroupCreateConsumer = 35, + [AsciiHash("xgroup-create")] XGroupCreate = 36, + [AsciiHash("xgroup-delconsumer")] XGroupDelConsumer = 37, + [AsciiHash("xgroup-destroy")] XGroupDestroy = 38, + [AsciiHash("xgroup-setid")] XGroupSetId = 39, + [AsciiHash("xsetid")] XSetId = 40, + [AsciiHash("xtrim")] XTrim = 41, + [AsciiHash("zadd")] ZAdd = 42, + [AsciiHash("zdiffstore")] ZDiffStore = 43, + [AsciiHash("zinterstore")] ZInterStore = 44, + [AsciiHash("zunionstore")] ZUnionStore = 45, + [AsciiHash("zincr")] ZIncr = 46, + [AsciiHash("zrembyrank")] ZRemByRank = 47, + [AsciiHash("zrembyscore")] ZRemByScore = 48, + [AsciiHash("zrem")] ZRem = 49, // side-effect notifications + [AsciiHash("expired")] Expired = 1000, + [AsciiHash("evicted")] Evicted = 1001, + [AsciiHash("new")] New = 1002, + [AsciiHash("overwritten")] Overwritten = 1003, - TypeChanged = 1004, // type_changed + [AsciiHash("type_changed")] + TypeChanged = 1004, #pragma warning restore CS1591 // docs, redundant } diff --git a/src/StackExchange.Redis/KeyNotificationTypeFastHash.cs b/src/StackExchange.Redis/KeyNotificationTypeFastHash.cs deleted file mode 100644 index bcf08bad2..000000000 --- a/src/StackExchange.Redis/KeyNotificationTypeFastHash.cs +++ /dev/null @@ -1,413 +0,0 @@ -using System; - -namespace StackExchange.Redis; - -/// -/// Internal helper type for fast parsing of key notification types, using [FastHash]. -/// -internal static partial class KeyNotificationTypeFastHash -{ - // these are checked by KeyNotificationTypeFastHash_MinMaxBytes_ReflectsActualLengths - public const int MinBytes = 3, MaxBytes = 21; - - public static KeyNotificationType Parse(ReadOnlySpan value) - { - var hash = value.Hash64(); - return hash switch - { - append.Hash when append.Is(hash, value) => KeyNotificationType.Append, - copy.Hash when copy.Is(hash, value) => KeyNotificationType.Copy, - del.Hash when del.Is(hash, value) => KeyNotificationType.Del, - expire.Hash when expire.Is(hash, value) => KeyNotificationType.Expire, - hdel.Hash when hdel.Is(hash, value) => KeyNotificationType.HDel, - hexpired.Hash when hexpired.Is(hash, value) => KeyNotificationType.HExpired, - hincrbyfloat.Hash when hincrbyfloat.Is(hash, value) => KeyNotificationType.HIncrByFloat, - hincrby.Hash when hincrby.Is(hash, value) => KeyNotificationType.HIncrBy, - hpersist.Hash when hpersist.Is(hash, value) => KeyNotificationType.HPersist, - hset.Hash when hset.Is(hash, value) => KeyNotificationType.HSet, - incrbyfloat.Hash when incrbyfloat.Is(hash, value) => KeyNotificationType.IncrByFloat, - incrby.Hash when incrby.Is(hash, value) => KeyNotificationType.IncrBy, - linsert.Hash when linsert.Is(hash, value) => KeyNotificationType.LInsert, - lpop.Hash when lpop.Is(hash, value) => KeyNotificationType.LPop, - lpush.Hash when lpush.Is(hash, value) => KeyNotificationType.LPush, - lrem.Hash when lrem.Is(hash, value) => KeyNotificationType.LRem, - lset.Hash when lset.Is(hash, value) => KeyNotificationType.LSet, - ltrim.Hash when ltrim.Is(hash, value) => KeyNotificationType.LTrim, - move_from.Hash when move_from.Is(hash, value) => KeyNotificationType.MoveFrom, - move_to.Hash when move_to.Is(hash, value) => KeyNotificationType.MoveTo, - persist.Hash when persist.Is(hash, value) => KeyNotificationType.Persist, - rename_from.Hash when rename_from.Is(hash, value) => KeyNotificationType.RenameFrom, - rename_to.Hash when rename_to.Is(hash, value) => KeyNotificationType.RenameTo, - restore.Hash when restore.Is(hash, value) => KeyNotificationType.Restore, - rpop.Hash when rpop.Is(hash, value) => KeyNotificationType.RPop, - rpush.Hash when rpush.Is(hash, value) => KeyNotificationType.RPush, - sadd.Hash when sadd.Is(hash, value) => KeyNotificationType.SAdd, - set.Hash when set.Is(hash, value) => KeyNotificationType.Set, - setrange.Hash when setrange.Is(hash, value) => KeyNotificationType.SetRange, - sortstore.Hash when sortstore.Is(hash, value) => KeyNotificationType.SortStore, - srem.Hash when srem.Is(hash, value) => KeyNotificationType.SRem, - spop.Hash when spop.Is(hash, value) => KeyNotificationType.SPop, - xadd.Hash when xadd.Is(hash, value) => KeyNotificationType.XAdd, - xdel.Hash when xdel.Is(hash, value) => KeyNotificationType.XDel, - xgroup_createconsumer.Hash when xgroup_createconsumer.Is(hash, value) => KeyNotificationType.XGroupCreateConsumer, - xgroup_create.Hash when xgroup_create.Is(hash, value) => KeyNotificationType.XGroupCreate, - xgroup_delconsumer.Hash when xgroup_delconsumer.Is(hash, value) => KeyNotificationType.XGroupDelConsumer, - xgroup_destroy.Hash when xgroup_destroy.Is(hash, value) => KeyNotificationType.XGroupDestroy, - xgroup_setid.Hash when xgroup_setid.Is(hash, value) => KeyNotificationType.XGroupSetId, - xsetid.Hash when xsetid.Is(hash, value) => KeyNotificationType.XSetId, - xtrim.Hash when xtrim.Is(hash, value) => KeyNotificationType.XTrim, - zadd.Hash when zadd.Is(hash, value) => KeyNotificationType.ZAdd, - zdiffstore.Hash when zdiffstore.Is(hash, value) => KeyNotificationType.ZDiffStore, - zinterstore.Hash when zinterstore.Is(hash, value) => KeyNotificationType.ZInterStore, - zunionstore.Hash when zunionstore.Is(hash, value) => KeyNotificationType.ZUnionStore, - zincr.Hash when zincr.Is(hash, value) => KeyNotificationType.ZIncr, - zrembyrank.Hash when zrembyrank.Is(hash, value) => KeyNotificationType.ZRemByRank, - zrembyscore.Hash when zrembyscore.Is(hash, value) => KeyNotificationType.ZRemByScore, - zrem.Hash when zrem.Is(hash, value) => KeyNotificationType.ZRem, - expired.Hash when expired.Is(hash, value) => KeyNotificationType.Expired, - evicted.Hash when evicted.Is(hash, value) => KeyNotificationType.Evicted, - _new.Hash when _new.Is(hash, value) => KeyNotificationType.New, - overwritten.Hash when overwritten.Is(hash, value) => KeyNotificationType.Overwritten, - type_changed.Hash when type_changed.Is(hash, value) => KeyNotificationType.TypeChanged, - _ => KeyNotificationType.Unknown, - }; - } - - internal static ReadOnlySpan GetRawBytes(KeyNotificationType type) - { - return type switch - { - KeyNotificationType.Append => append.U8, - KeyNotificationType.Copy => copy.U8, - KeyNotificationType.Del => del.U8, - KeyNotificationType.Expire => expire.U8, - KeyNotificationType.HDel => hdel.U8, - KeyNotificationType.HExpired => hexpired.U8, - KeyNotificationType.HIncrByFloat => hincrbyfloat.U8, - KeyNotificationType.HIncrBy => hincrby.U8, - KeyNotificationType.HPersist => hpersist.U8, - KeyNotificationType.HSet => hset.U8, - KeyNotificationType.IncrByFloat => incrbyfloat.U8, - KeyNotificationType.IncrBy => incrby.U8, - KeyNotificationType.LInsert => linsert.U8, - KeyNotificationType.LPop => lpop.U8, - KeyNotificationType.LPush => lpush.U8, - KeyNotificationType.LRem => lrem.U8, - KeyNotificationType.LSet => lset.U8, - KeyNotificationType.LTrim => ltrim.U8, - KeyNotificationType.MoveFrom => move_from.U8, - KeyNotificationType.MoveTo => move_to.U8, - KeyNotificationType.Persist => persist.U8, - KeyNotificationType.RenameFrom => rename_from.U8, - KeyNotificationType.RenameTo => rename_to.U8, - KeyNotificationType.Restore => restore.U8, - KeyNotificationType.RPop => rpop.U8, - KeyNotificationType.RPush => rpush.U8, - KeyNotificationType.SAdd => sadd.U8, - KeyNotificationType.Set => set.U8, - KeyNotificationType.SetRange => setrange.U8, - KeyNotificationType.SortStore => sortstore.U8, - KeyNotificationType.SRem => srem.U8, - KeyNotificationType.SPop => spop.U8, - KeyNotificationType.XAdd => xadd.U8, - KeyNotificationType.XDel => xdel.U8, - KeyNotificationType.XGroupCreateConsumer => xgroup_createconsumer.U8, - KeyNotificationType.XGroupCreate => xgroup_create.U8, - KeyNotificationType.XGroupDelConsumer => xgroup_delconsumer.U8, - KeyNotificationType.XGroupDestroy => xgroup_destroy.U8, - KeyNotificationType.XGroupSetId => xgroup_setid.U8, - KeyNotificationType.XSetId => xsetid.U8, - KeyNotificationType.XTrim => xtrim.U8, - KeyNotificationType.ZAdd => zadd.U8, - KeyNotificationType.ZDiffStore => zdiffstore.U8, - KeyNotificationType.ZInterStore => zinterstore.U8, - KeyNotificationType.ZUnionStore => zunionstore.U8, - KeyNotificationType.ZIncr => zincr.U8, - KeyNotificationType.ZRemByRank => zrembyrank.U8, - KeyNotificationType.ZRemByScore => zrembyscore.U8, - KeyNotificationType.ZRem => zrem.U8, - KeyNotificationType.Expired => expired.U8, - KeyNotificationType.Evicted => evicted.U8, - KeyNotificationType.New => _new.U8, - KeyNotificationType.Overwritten => overwritten.U8, - KeyNotificationType.TypeChanged => type_changed.U8, - _ => Throw(), - }; - static ReadOnlySpan Throw() => throw new ArgumentOutOfRangeException(nameof(type)); - } - -#pragma warning disable SA1300, CS8981 - // ReSharper disable InconsistentNaming - [FastHash] - internal static partial class append - { - } - - [FastHash] - internal static partial class copy - { - } - - [FastHash] - internal static partial class del - { - } - - [FastHash] - internal static partial class expire - { - } - - [FastHash] - internal static partial class hdel - { - } - - [FastHash] - internal static partial class hexpired - { - } - - [FastHash] - internal static partial class hincrbyfloat - { - } - - [FastHash] - internal static partial class hincrby - { - } - - [FastHash] - internal static partial class hpersist - { - } - - [FastHash] - internal static partial class hset - { - } - - [FastHash] - internal static partial class incrbyfloat - { - } - - [FastHash] - internal static partial class incrby - { - } - - [FastHash] - internal static partial class linsert - { - } - - [FastHash] - internal static partial class lpop - { - } - - [FastHash] - internal static partial class lpush - { - } - - [FastHash] - internal static partial class lrem - { - } - - [FastHash] - internal static partial class lset - { - } - - [FastHash] - internal static partial class ltrim - { - } - - [FastHash("move_from")] // by default, the generator interprets underscore as hyphen - internal static partial class move_from - { - } - - [FastHash("move_to")] // by default, the generator interprets underscore as hyphen - internal static partial class move_to - { - } - - [FastHash] - internal static partial class persist - { - } - - [FastHash("rename_from")] // by default, the generator interprets underscore as hyphen - internal static partial class rename_from - { - } - - [FastHash("rename_to")] // by default, the generator interprets underscore as hyphen - internal static partial class rename_to - { - } - - [FastHash] - internal static partial class restore - { - } - - [FastHash] - internal static partial class rpop - { - } - - [FastHash] - internal static partial class rpush - { - } - - [FastHash] - internal static partial class sadd - { - } - - [FastHash] - internal static partial class set - { - } - - [FastHash] - internal static partial class setrange - { - } - - [FastHash] - internal static partial class sortstore - { - } - - [FastHash] - internal static partial class srem - { - } - - [FastHash] - internal static partial class spop - { - } - - [FastHash] - internal static partial class xadd - { - } - - [FastHash] - internal static partial class xdel - { - } - - [FastHash] // note: becomes hyphenated - internal static partial class xgroup_createconsumer - { - } - - [FastHash] // note: becomes hyphenated - internal static partial class xgroup_create - { - } - - [FastHash] // note: becomes hyphenated - internal static partial class xgroup_delconsumer - { - } - - [FastHash] // note: becomes hyphenated - internal static partial class xgroup_destroy - { - } - - [FastHash] // note: becomes hyphenated - internal static partial class xgroup_setid - { - } - - [FastHash] - internal static partial class xsetid - { - } - - [FastHash] - internal static partial class xtrim - { - } - - [FastHash] - internal static partial class zadd - { - } - - [FastHash] - internal static partial class zdiffstore - { - } - - [FastHash] - internal static partial class zinterstore - { - } - - [FastHash] - internal static partial class zunionstore - { - } - - [FastHash] - internal static partial class zincr - { - } - - [FastHash] - internal static partial class zrembyrank - { - } - - [FastHash] - internal static partial class zrembyscore - { - } - - [FastHash] - internal static partial class zrem - { - } - - [FastHash] - internal static partial class expired - { - } - - [FastHash] - internal static partial class evicted - { - } - - [FastHash("new")] - internal static partial class _new // it isn't worth making the code-gen keyword aware - { - } - - [FastHash] - internal static partial class overwritten - { - } - - [FastHash("type_changed")] // by default, the generator interprets underscore as hyphen - internal static partial class type_changed - { - } - - // ReSharper restore InconsistentNaming -#pragma warning restore SA1300, CS8981 -} diff --git a/src/StackExchange.Redis/KeyNotificationTypeMetadata.cs b/src/StackExchange.Redis/KeyNotificationTypeMetadata.cs new file mode 100644 index 000000000..594fd29c2 --- /dev/null +++ b/src/StackExchange.Redis/KeyNotificationTypeMetadata.cs @@ -0,0 +1,77 @@ +using System; +using RESPite; + +namespace StackExchange.Redis; + +/// +/// Metadata and parsing methods for KeyNotificationType. +/// +internal static partial class KeyNotificationTypeMetadata +{ + [AsciiHash] + internal static partial bool TryParse(ReadOnlySpan value, out KeyNotificationType keyNotificationType); + + public static KeyNotificationType Parse(ReadOnlySpan value) + { + return TryParse(value, out var result) ? result : KeyNotificationType.Unknown; + } + + internal static ReadOnlySpan GetRawBytes(KeyNotificationType type) => type switch + { + KeyNotificationType.Append => "append"u8, + KeyNotificationType.Copy => "copy"u8, + KeyNotificationType.Del => "del"u8, + KeyNotificationType.Expire => "expire"u8, + KeyNotificationType.HDel => "hdel"u8, + KeyNotificationType.HExpired => "hexpired"u8, + KeyNotificationType.HIncrByFloat => "hincrbyfloat"u8, + KeyNotificationType.HIncrBy => "hincrby"u8, + KeyNotificationType.HPersist => "hpersist"u8, + KeyNotificationType.HSet => "hset"u8, + KeyNotificationType.IncrByFloat => "incrbyfloat"u8, + KeyNotificationType.IncrBy => "incrby"u8, + KeyNotificationType.LInsert => "linsert"u8, + KeyNotificationType.LPop => "lpop"u8, + KeyNotificationType.LPush => "lpush"u8, + KeyNotificationType.LRem => "lrem"u8, + KeyNotificationType.LSet => "lset"u8, + KeyNotificationType.LTrim => "ltrim"u8, + KeyNotificationType.MoveFrom => "move_from"u8, + KeyNotificationType.MoveTo => "move_to"u8, + KeyNotificationType.Persist => "persist"u8, + KeyNotificationType.RenameFrom => "rename_from"u8, + KeyNotificationType.RenameTo => "rename_to"u8, + KeyNotificationType.Restore => "restore"u8, + KeyNotificationType.RPop => "rpop"u8, + KeyNotificationType.RPush => "rpush"u8, + KeyNotificationType.SAdd => "sadd"u8, + KeyNotificationType.Set => "set"u8, + KeyNotificationType.SetRange => "setrange"u8, + KeyNotificationType.SortStore => "sortstore"u8, + KeyNotificationType.SRem => "srem"u8, + KeyNotificationType.SPop => "spop"u8, + KeyNotificationType.XAdd => "xadd"u8, + KeyNotificationType.XDel => "xdel"u8, + KeyNotificationType.XGroupCreateConsumer => "xgroup-createconsumer"u8, + KeyNotificationType.XGroupCreate => "xgroup-create"u8, + KeyNotificationType.XGroupDelConsumer => "xgroup-delconsumer"u8, + KeyNotificationType.XGroupDestroy => "xgroup-destroy"u8, + KeyNotificationType.XGroupSetId => "xgroup-setid"u8, + KeyNotificationType.XSetId => "xsetid"u8, + KeyNotificationType.XTrim => "xtrim"u8, + KeyNotificationType.ZAdd => "zadd"u8, + KeyNotificationType.ZDiffStore => "zdiffstore"u8, + KeyNotificationType.ZInterStore => "zinterstore"u8, + KeyNotificationType.ZUnionStore => "zunionstore"u8, + KeyNotificationType.ZIncr => "zincr"u8, + KeyNotificationType.ZRemByRank => "zrembyrank"u8, + KeyNotificationType.ZRemByScore => "zrembyscore"u8, + KeyNotificationType.ZRem => "zrem"u8, + KeyNotificationType.Expired => "expired"u8, + KeyNotificationType.Evicted => "evicted"u8, + KeyNotificationType.New => "new"u8, + KeyNotificationType.Overwritten => "overwritten"u8, + KeyNotificationType.TypeChanged => "type_changed"u8, + _ => throw new ArgumentOutOfRangeException(nameof(type)), + }; +} diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.VectorSets.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.VectorSets.cs index ae7498401..ad4efe916 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.VectorSets.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.VectorSets.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; +using RESPite; // ReSharper disable once CheckNamespace namespace StackExchange.Redis.KeyspaceIsolation; diff --git a/src/StackExchange.Redis/PhysicalConnection.cs b/src/StackExchange.Redis/PhysicalConnection.cs index 1af5589cf..6320f709f 100644 --- a/src/StackExchange.Redis/PhysicalConnection.cs +++ b/src/StackExchange.Redis/PhysicalConnection.cs @@ -19,6 +19,7 @@ using Microsoft.Extensions.Logging; using Pipelines.Sockets.Unofficial; using Pipelines.Sockets.Unofficial.Arenas; +using RESPite; using static StackExchange.Redis.Message; namespace StackExchange.Redis @@ -825,9 +826,9 @@ internal void Write(in RedisChannel channel) [MethodImpl(MethodImplOptions.AggressiveInlining)] internal void WriteBulkString(in RedisValue value) => WriteBulkString(value, _ioPipe?.Output); - internal static void WriteBulkString(in RedisValue value, PipeWriter? maybeNullWriter) + internal static void WriteBulkString(in RedisValue value, IBufferWriter? maybeNullWriter) { - if (maybeNullWriter is not PipeWriter writer) + if (maybeNullWriter is not { } writer) { return; // Prevent null refs during disposal } @@ -916,7 +917,7 @@ internal void RecordQuit() (_ioPipe as SocketConnection)?.TrySetProtocolShutdown(PipeShutdownKind.ProtocolExitClient); } - internal static void WriteMultiBulkHeader(PipeWriter output, long count) + internal static void WriteMultiBulkHeader(IBufferWriter output, long count) { // *{count}\r\n = 3 + MaxInt32TextLen var span = output.GetSpan(3 + Format.MaxInt32TextLen); @@ -925,7 +926,7 @@ internal static void WriteMultiBulkHeader(PipeWriter output, long count) output.Advance(offset); } - internal static void WriteMultiBulkHeader(PipeWriter output, long count, ResultType type) + internal static void WriteMultiBulkHeader(IBufferWriter output, long count, ResultType type) { // *{count}\r\n = 3 + MaxInt32TextLen var span = output.GetSpan(3 + Format.MaxInt32TextLen); @@ -958,7 +959,7 @@ internal static int WriteCrlf(Span span, int offset) } [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static void WriteCrlf(PipeWriter writer) + internal static void WriteCrlf(IBufferWriter writer) { var span = writer.GetSpan(2); span[0] = (byte)'\r'; @@ -1122,7 +1123,7 @@ internal ValueTask FlushAsync(bool throwOnFailure, CancellationToke private static readonly ReadOnlyMemory NullBulkString = Encoding.ASCII.GetBytes("$-1\r\n"), EmptyBulkString = Encoding.ASCII.GetBytes("$0\r\n\r\n"); - private static void WriteUnifiedBlob(PipeWriter writer, byte[]? value) + private static void WriteUnifiedBlob(IBufferWriter writer, byte[]? value) { if (value == null) { @@ -1135,7 +1136,7 @@ private static void WriteUnifiedBlob(PipeWriter writer, byte[]? value) } } - private static void WriteUnifiedSpan(PipeWriter writer, ReadOnlySpan value) + private static void WriteUnifiedSpan(IBufferWriter writer, ReadOnlySpan value) { // ${len}\r\n = 3 + MaxInt32TextLen // {value}\r\n = 2 + value.Length @@ -1229,9 +1230,9 @@ internal static byte ToHexNibble(int value) return value < 10 ? (byte)('0' + value) : (byte)('a' - 10 + value); } - internal static void WriteUnifiedPrefixedString(PipeWriter? maybeNullWriter, byte[]? prefix, string? value) + internal static void WriteUnifiedPrefixedString(IBufferWriter? maybeNullWriter, byte[]? prefix, string? value) { - if (maybeNullWriter is not PipeWriter writer) + if (maybeNullWriter is not { } writer) { return; // Prevent null refs during disposal } @@ -1284,7 +1285,7 @@ internal static Encoder GetPerThreadEncoder() return encoder; } - internal static unsafe void WriteRaw(PipeWriter writer, string value, int expectedLength) + internal static unsafe void WriteRaw(IBufferWriter writer, string value, int expectedLength) { const int MaxQuickEncodeSize = 512; @@ -1368,7 +1369,7 @@ private static void WriteUnifiedPrefixedBlob(PipeWriter? maybeNullWriter, byte[] } } - private static void WriteUnifiedInt64(PipeWriter writer, long value) + private static void WriteUnifiedInt64(IBufferWriter writer, long value) { // note from specification: A client sends to the Redis server a RESP Array consisting of just Bulk Strings. // (i.e. we can't just send ":123\r\n", we need to send "$3\r\n123\r\n" @@ -1382,7 +1383,7 @@ private static void WriteUnifiedInt64(PipeWriter writer, long value) writer.Advance(bytes); } - private static void WriteUnifiedUInt64(PipeWriter writer, ulong value) + private static void WriteUnifiedUInt64(IBufferWriter writer, ulong value) { // note from specification: A client sends to the Redis server a RESP Array consisting of just Bulk Strings. // (i.e. we can't just send ":123\r\n", we need to send "$3\r\n123\r\n" @@ -1400,7 +1401,7 @@ private static void WriteUnifiedUInt64(PipeWriter writer, ulong value) writer.Advance(offset); } - private static void WriteUnifiedDouble(PipeWriter writer, double value) + private static void WriteUnifiedDouble(IBufferWriter writer, double value) { #if NET8_0_OR_GREATER Span valueSpan = stackalloc byte[Format.MaxDoubleTextLen]; @@ -1421,7 +1422,7 @@ private static void WriteUnifiedDouble(PipeWriter writer, double value) #endif } - internal static void WriteInteger(PipeWriter writer, long value) + internal static void WriteInteger(IBufferWriter writer, long value) { // note: client should never write integer; only server does this // :{asc}\r\n = MaxInt64TextLen + 3 @@ -1691,19 +1692,36 @@ internal async ValueTask ConnectedAsync(Socket? socket, ILogger? log, Sock } } - private enum PushKind + internal enum PushKind { + [AsciiHash("")] None, + [AsciiHash("message")] Message, + [AsciiHash("pmessage")] PMessage, + [AsciiHash("smessage")] SMessage, + [AsciiHash("subscribe")] Subscribe, + [AsciiHash("psubscribe")] PSubscribe, + [AsciiHash("ssubscribe")] SSubscribe, + [AsciiHash("unsubscribe")] Unsubscribe, + [AsciiHash("punsubscribe")] PUnsubscribe, + [AsciiHash("sunsubscribe")] SUnsubscribe, } + + internal static partial class PushKindMetadata + { + [AsciiHash] + internal static partial bool TryParse(ReadOnlySpan value, out PushKind result); + } + private PushKind GetPushKind(in Sequence result, out RedisChannel channel) { var len = result.Length; @@ -1714,63 +1732,40 @@ private PushKind GetPushKind(in Sequence result, out RedisChannel cha return PushKind.None; } - const int MAX_LEN = 16; - Debug.Assert(MAX_LEN >= Enumerable.Max( - [ - PushMessage.Length, PushPMessage.Length, PushSMessage.Length, - PushSubscribe.Length, PushPSubscribe.Length, PushSSubscribe.Length, - PushUnsubscribe.Length, PushPUnsubscribe.Length, PushSUnsubscribe.Length, - ])); - ref readonly RawResult pushKind = ref result[0]; - var multiSegmentPayload = pushKind.Payload; - if (multiSegmentPayload.Length <= MAX_LEN) + if (result[0].TryParse(PushKindMetadata.TryParse, out PushKind kind) && kind is not PushKind.None) { - var span = multiSegmentPayload.IsSingleSegment - ? multiSegmentPayload.First.Span - : CopyTo(stackalloc byte[MAX_LEN], multiSegmentPayload); - - var hash = FastHash.Hash64(span); RedisChannel.RedisChannelOptions channelOptions = RedisChannel.RedisChannelOptions.None; - PushKind kind; - switch (hash) + switch (kind) { - case PushMessage.Hash when PushMessage.Is(hash, span) & len >= 3: - kind = PushKind.Message; + case PushKind.Message when len >= 3: break; - case PushPMessage.Hash when PushPMessage.Is(hash, span) & len >= 4: + case PushKind.PMessage when len >= 4: channelOptions = RedisChannel.RedisChannelOptions.Pattern; - kind = PushKind.PMessage; break; - case PushSMessage.Hash when PushSMessage.Is(hash, span) & len >= 3: + case PushKind.SMessage when len >= 3: channelOptions = RedisChannel.RedisChannelOptions.Sharded; - kind = PushKind.SMessage; break; - case PushSubscribe.Hash when PushSubscribe.Is(hash, span): - kind = PushKind.Subscribe; + case PushKind.Subscribe: break; - case PushPSubscribe.Hash when PushPSubscribe.Is(hash, span): + case PushKind.PSubscribe: channelOptions = RedisChannel.RedisChannelOptions.Pattern; - kind = PushKind.PSubscribe; break; - case PushSSubscribe.Hash when PushSSubscribe.Is(hash, span): + case PushKind.SSubscribe: channelOptions = RedisChannel.RedisChannelOptions.Sharded; - kind = PushKind.SSubscribe; break; - case PushUnsubscribe.Hash when PushUnsubscribe.Is(hash, span): - kind = PushKind.Unsubscribe; + case PushKind.Unsubscribe: break; - case PushPUnsubscribe.Hash when PushPUnsubscribe.Is(hash, span): + case PushKind.PUnsubscribe: channelOptions = RedisChannel.RedisChannelOptions.Pattern; - kind = PushKind.PUnsubscribe; break; - case PushSUnsubscribe.Hash when PushSUnsubscribe.Is(hash, span): + case PushKind.SUnsubscribe: channelOptions = RedisChannel.RedisChannelOptions.Sharded; - kind = PushKind.SUnsubscribe; break; default: kind = PushKind.None; break; } + if (kind != PushKind.None) { // the channel is always the second element @@ -1780,41 +1775,8 @@ private PushKind GetPushKind(in Sequence result, out RedisChannel cha } channel = default; return PushKind.None; - - static ReadOnlySpan CopyTo(Span target, in ReadOnlySequence source) - { - source.CopyTo(target); - return target.Slice(0, (int)source.Length); - } } - [FastHash("message")] - private static partial class PushMessage { } - - [FastHash("pmessage")] - private static partial class PushPMessage { } - - [FastHash("smessage")] - private static partial class PushSMessage { } - - [FastHash("subscribe")] - private static partial class PushSubscribe { } - - [FastHash("psubscribe")] - private static partial class PushPSubscribe { } - - [FastHash("ssubscribe")] - private static partial class PushSSubscribe { } - - [FastHash("unsubscribe")] - private static partial class PushUnsubscribe { } - - [FastHash("punsubscribe")] - private static partial class PushPUnsubscribe { } - - [FastHash("sunsubscribe")] - private static partial class PushSUnsubscribe { } - private void MatchResult(in RawResult result) { // check to see if it could be an out-of-band pubsub message @@ -1902,7 +1864,7 @@ private void MatchResult(in RawResult result) // counter-intuitively, the only server we *know* already knows the new route is: // the outgoing server, since it had to change to MIGRATING etc; the new INCOMING server // knows, but *we don't know who that is*, and other nodes: aren't guaranteed to know (yet) - muxer.DefaultSubscriber.ResubscribeToServer(subscription, subscriptionChannel, server, cause: PushSUnsubscribe.Text); + muxer.DefaultSubscriber.ResubscribeToServer(subscription, subscriptionChannel, server, cause: "sunsubscribe"); } return; // and STOP PROCESSING; unsolicited } diff --git a/src/StackExchange.Redis/RawResult.cs b/src/StackExchange.Redis/RawResult.cs index e1c91b74e..9496aa91c 100644 --- a/src/StackExchange.Redis/RawResult.cs +++ b/src/StackExchange.Redis/RawResult.cs @@ -12,6 +12,31 @@ internal readonly struct RawResult internal int ItemsCount => (int)_items.Length; + public delegate bool ScalarParser(scoped ReadOnlySpan span, out T value); + + internal bool TryParse(ScalarParser parser, out T value) + => _payload.IsSingleSegment ? parser(_payload.First.Span, out value) : TryParseSlow(parser, out value); + + private bool TryParseSlow(ScalarParser parser, out T value) + { + // linearize a multi-segment payload into a single span for parsing + const int MAX_STACK = 64; + var len = checked((int)_payload.Length); + byte[]? lease = null; + try + { + Span span = + (len <= MAX_STACK ? stackalloc byte[MAX_STACK] : (lease = ArrayPool.Shared.Rent(len))) + .Slice(0, len); + _payload.CopyTo(span); + return parser(span, out value); + } + finally + { + if (lease is not null) ArrayPool.Shared.Return(lease); + } + } + private readonly ReadOnlySequence _payload; internal ReadOnlySequence Payload => _payload; diff --git a/src/StackExchange.Redis/RedisChannel.cs b/src/StackExchange.Redis/RedisChannel.cs index 889525bd2..c3acf1493 100644 --- a/src/StackExchange.Redis/RedisChannel.cs +++ b/src/StackExchange.Redis/RedisChannel.cs @@ -269,7 +269,7 @@ private static ReadOnlySpan AppendDatabase(Span target, int? databas #pragma warning disable RS0027 public static RedisChannel KeyEvent(KeyNotificationType type, int? database = null) #pragma warning restore RS0027 - => KeyEvent(KeyNotificationTypeFastHash.GetRawBytes(type), database); + => KeyEvent(KeyNotificationTypeMetadata.GetRawBytes(type), database); /// /// Create an event-notification channel for a given event type, optionally in a specified database. diff --git a/src/StackExchange.Redis/RedisLiterals.cs b/src/StackExchange.Redis/RedisLiterals.cs index be79b3267..9a8f54971 100644 --- a/src/StackExchange.Redis/RedisLiterals.cs +++ b/src/StackExchange.Redis/RedisLiterals.cs @@ -39,31 +39,6 @@ public static readonly CommandBytes id = "id"; } - internal static partial class CommonRepliesHash - { -#pragma warning disable CS8981, SA1300, SA1134 // forgive naming - // ReSharper disable InconsistentNaming - [FastHash] internal static partial class length { } - [FastHash] internal static partial class radix_tree_keys { } - [FastHash] internal static partial class radix_tree_nodes { } - [FastHash] internal static partial class last_generated_id { } - [FastHash] internal static partial class max_deleted_entry_id { } - [FastHash] internal static partial class entries_added { } - [FastHash] internal static partial class recorded_first_entry_id { } - [FastHash] internal static partial class idmp_duration { } - [FastHash] internal static partial class idmp_maxsize { } - [FastHash] internal static partial class pids_tracked { } - [FastHash] internal static partial class first_entry { } - [FastHash] internal static partial class last_entry { } - [FastHash] internal static partial class groups { } - [FastHash] internal static partial class iids_tracked { } - [FastHash] internal static partial class iids_added { } - [FastHash] internal static partial class iids_duplicates { } - - // ReSharper restore InconsistentNaming -#pragma warning restore CS8981, SA1300, SA1134 // forgive naming - } - internal static class RedisLiterals { // unlike primary commands, these do not get altered by the command-map; we may as diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index 46228a912..5b8bfe58f 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -3,6 +3,7 @@ using System.Buffers.Text; using System.ComponentModel; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Reflection; @@ -1367,5 +1368,31 @@ public bool StartsWith(ReadOnlySpan value) return false; } } + + // used by the toy server to smuggle weird vectors; on their own heads... not used by SE.Redis itself + // (these additions just formalize the usage in the older server code) + internal bool TryGetForeign([NotNullWhen(true)] out T? value, out int index, out int length) + where T : class + { + if (typeof(T) != typeof(string) && typeof(T) != typeof(byte[]) && DirectObject is T found) + { + index = 0; + length = checked((int)DirectOverlappedBits64); + value = found; + return true; + } + value = null; + index = 0; + length = 0; + return false; + } + + internal static RedisValue CreateForeign(T obj, int offset, int count) where T : class + { + // non-zero offset isn't supported until v3, left here for API parity + if (typeof(T) == typeof(string) || typeof(T) == typeof(byte[]) || offset != 0) Throw(); + return new RedisValue(obj, count); + static void Throw() => throw new InvalidOperationException(); + } } } diff --git a/src/StackExchange.Redis/ResultProcessor.VectorSets.cs b/src/StackExchange.Redis/ResultProcessor.VectorSets.cs index b10e5fd93..f8f3bed72 100644 --- a/src/StackExchange.Redis/ResultProcessor.VectorSets.cs +++ b/src/StackExchange.Redis/ResultProcessor.VectorSets.cs @@ -73,49 +73,36 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes var iter = result.GetItems().GetEnumerator(); while (iter.MoveNext()) { - ref readonly RawResult key = ref iter.Current; + if (!iter.Current.TryParse(VectorSetInfoFieldMetadata.TryParse, out VectorSetInfoField field)) + field = VectorSetInfoField.Unknown; + if (!iter.MoveNext()) break; ref readonly RawResult value = ref iter.Current; - var len = key.Payload.Length; - var keyHash = key.Payload.Hash64(); - switch (key.Payload.Length) + switch (field) { - case size.Length when size.Is(keyHash, key) && value.TryGetInt64(out var i64): + case VectorSetInfoField.Size when value.TryGetInt64(out var i64): resultSize = i64; break; - case vset_uid.Length when vset_uid.Is(keyHash, key) && value.TryGetInt64(out var i64): + case VectorSetInfoField.VsetUid when value.TryGetInt64(out var i64): vsetUid = i64; break; - case max_level.Length when max_level.Is(keyHash, key) && value.TryGetInt64(out var i64): + case VectorSetInfoField.MaxLevel when value.TryGetInt64(out var i64): maxLevel = checked((int)i64); break; - case vector_dim.Length - when vector_dim.Is(keyHash, key) && value.TryGetInt64(out var i64): + case VectorSetInfoField.VectorDim when value.TryGetInt64(out var i64): vectorDim = checked((int)i64); break; - case quant_type.Length when quant_type.Is(keyHash, key): - var qHash = value.Payload.Hash64(); - switch (value.Payload.Length) - { - case bin.Length when bin.Is(qHash, value): - quantType = VectorSetQuantization.Binary; - break; - case f32.Length when f32.Is(qHash, value): - quantType = VectorSetQuantization.None; - break; - case int8.Length when int8.Is(qHash, value): - quantType = VectorSetQuantization.Int8; - break; - default: - quantTypeRaw = value.GetString(); - quantType = VectorSetQuantization.Unknown; - break; - } - + case VectorSetInfoField.QuantType + when value.TryParse(VectorSetQuantizationMetadata.TryParse, out VectorSetQuantization quantTypeValue) + && quantTypeValue is not VectorSetQuantization.Unknown: + quantType = quantTypeValue; break; - case hnsw_max_node_uid.Length - when hnsw_max_node_uid.Is(keyHash, key) && value.TryGetInt64(out var i64): + case VectorSetInfoField.QuantType: + quantTypeRaw = value.GetString(); + quantType = VectorSetQuantization.Unknown; + break; + case VectorSetInfoField.HnswMaxNodeUid when value.TryGetInt64(out var i64): hnswMaxNodeUid = i64; break; } @@ -129,21 +116,5 @@ when hnsw_max_node_uid.Is(keyHash, key) && value.TryGetInt64(out var i64): return false; } - -#pragma warning disable CS8981, SA1134, SA1300, SA1303, SA1502 - // ReSharper disable InconsistentNaming - to better represent expected literals - // ReSharper disable IdentifierTypo - [FastHash] private static partial class bin { } - [FastHash] private static partial class f32 { } - [FastHash] private static partial class int8 { } - [FastHash] private static partial class size { } - [FastHash] private static partial class vset_uid { } - [FastHash] private static partial class max_level { } - [FastHash] private static partial class quant_type { } - [FastHash] private static partial class vector_dim { } - [FastHash] private static partial class hnsw_max_node_uid { } - // ReSharper restore InconsistentNaming - // ReSharper restore IdentifierTypo -#pragma warning restore CS8981, SA1134, SA1300, SA1303, SA1502 } } diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index 0562d7a4c..777bd0571 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -2554,59 +2554,60 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes var iter = arr.GetEnumerator(); for (int i = 0; i < max; i++) { - ref RawResult key = ref iter.GetNext(), value = ref iter.GetNext(); - if (key.Payload.Length > CommandBytes.MaxLength) continue; - var hash = key.Payload.Hash64(); - switch (hash) + if (!iter.GetNext().TryParse(StreamInfoFieldMetadata.TryParse, out StreamInfoField field)) + field = StreamInfoField.Unknown; + ref RawResult value = ref iter.GetNext(); + + switch (field) { - case CommonRepliesHash.length.Hash when CommonRepliesHash.length.Is(hash, key): + case StreamInfoField.Length: if (!value.TryGetInt64(out length)) return false; break; - case CommonRepliesHash.radix_tree_keys.Hash when CommonRepliesHash.radix_tree_keys.Is(hash, key): + case StreamInfoField.RadixTreeKeys: if (!value.TryGetInt64(out radixTreeKeys)) return false; break; - case CommonRepliesHash.radix_tree_nodes.Hash when CommonRepliesHash.radix_tree_nodes.Is(hash, key): + case StreamInfoField.RadixTreeNodes: if (!value.TryGetInt64(out radixTreeNodes)) return false; break; - case CommonRepliesHash.groups.Hash when CommonRepliesHash.groups.Is(hash, key): + case StreamInfoField.Groups: if (!value.TryGetInt64(out groups)) return false; break; - case CommonRepliesHash.last_generated_id.Hash when CommonRepliesHash.last_generated_id.Is(hash, key): + case StreamInfoField.LastGeneratedId: lastGeneratedId = value.AsRedisValue(); break; - case CommonRepliesHash.first_entry.Hash when CommonRepliesHash.first_entry.Is(hash, key): + case StreamInfoField.FirstEntry: firstEntry = ParseRedisStreamEntry(value); break; - case CommonRepliesHash.last_entry.Hash when CommonRepliesHash.last_entry.Is(hash, key): + case StreamInfoField.LastEntry: lastEntry = ParseRedisStreamEntry(value); break; // 7.0 - case CommonRepliesHash.max_deleted_entry_id.Hash when CommonRepliesHash.max_deleted_entry_id.Is(hash, key): + case StreamInfoField.MaxDeletedEntryId: maxDeletedEntryId = value.AsRedisValue(); break; - case CommonRepliesHash.recorded_first_entry_id.Hash when CommonRepliesHash.recorded_first_entry_id.Is(hash, key): + case StreamInfoField.RecordedFirstEntryId: recordedFirstEntryId = value.AsRedisValue(); break; - case CommonRepliesHash.entries_added.Hash when CommonRepliesHash.entries_added.Is(hash, key): + case StreamInfoField.EntriesAdded: if (!value.TryGetInt64(out entriesAdded)) return false; break; // 8.6 - case CommonRepliesHash.idmp_duration.Hash when CommonRepliesHash.idmp_duration.Is(hash, key): + case StreamInfoField.IdmpDuration: if (!value.TryGetInt64(out idmpDuration)) return false; break; - case CommonRepliesHash.idmp_maxsize.Hash when CommonRepliesHash.idmp_maxsize.Is(hash, key): + case StreamInfoField.IdmpMaxsize: if (!value.TryGetInt64(out idmpMaxsize)) return false; break; - case CommonRepliesHash.pids_tracked.Hash when CommonRepliesHash.pids_tracked.Is(hash, key): + case StreamInfoField.PidsTracked: if (!value.TryGetInt64(out pidsTracked)) return false; break; - case CommonRepliesHash.iids_tracked.Hash when CommonRepliesHash.iids_tracked.Is(hash, key): + case StreamInfoField.IidsTracked: if (!value.TryGetInt64(out iidsTracked)) return false; break; - case CommonRepliesHash.iids_added.Hash when CommonRepliesHash.iids_added.Is(hash, key): + case StreamInfoField.IidsAdded: if (!value.TryGetInt64(out iidsAdded)) return false; break; - case CommonRepliesHash.iids_duplicates.Hash when CommonRepliesHash.iids_duplicates.Is(hash, key): + case StreamInfoField.IidsDuplicates: if (!value.TryGetInt64(out iidsDuplicates)) return false; break; } diff --git a/src/StackExchange.Redis/StackExchange.Redis.csproj b/src/StackExchange.Redis/StackExchange.Redis.csproj index 2c2e7702a..20b772bc2 100644 --- a/src/StackExchange.Redis/StackExchange.Redis.csproj +++ b/src/StackExchange.Redis/StackExchange.Redis.csproj @@ -56,4 +56,10 @@ + + + + + + \ No newline at end of file diff --git a/src/StackExchange.Redis/StreamConfiguration.cs b/src/StackExchange.Redis/StreamConfiguration.cs index 71bbe483e..46e5d0ba3 100644 --- a/src/StackExchange.Redis/StreamConfiguration.cs +++ b/src/StackExchange.Redis/StreamConfiguration.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using RESPite; namespace StackExchange.Redis; diff --git a/src/StackExchange.Redis/StreamIdempotentId.cs b/src/StackExchange.Redis/StreamIdempotentId.cs index 601890d1f..1ad331eda 100644 --- a/src/StackExchange.Redis/StreamIdempotentId.cs +++ b/src/StackExchange.Redis/StreamIdempotentId.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; +using RESPite; namespace StackExchange.Redis; diff --git a/src/StackExchange.Redis/StreamInfoField.cs b/src/StackExchange.Redis/StreamInfoField.cs new file mode 100644 index 000000000..5429dec5e --- /dev/null +++ b/src/StackExchange.Redis/StreamInfoField.cs @@ -0,0 +1,121 @@ +using System; +using RESPite; + +namespace StackExchange.Redis; + +/// +/// Fields that can appear in a XINFO STREAM response. +/// +internal enum StreamInfoField +{ + /// + /// Unknown or unrecognized field. + /// + [AsciiHash("")] + Unknown = 0, + + /// + /// The number of entries in the stream. + /// + [AsciiHash("length")] + Length, + + /// + /// The number of radix tree keys. + /// + [AsciiHash("radix-tree-keys")] + RadixTreeKeys, + + /// + /// The number of radix tree nodes. + /// + [AsciiHash("radix-tree-nodes")] + RadixTreeNodes, + + /// + /// The number of consumer groups. + /// + [AsciiHash("groups")] + Groups, + + /// + /// The last generated ID. + /// + [AsciiHash("last-generated-id")] + LastGeneratedId, + + /// + /// The first entry in the stream. + /// + [AsciiHash("first-entry")] + FirstEntry, + + /// + /// The last entry in the stream. + /// + [AsciiHash("last-entry")] + LastEntry, + + /// + /// The maximum deleted entry ID (Redis 7.0+). + /// + [AsciiHash("max-deleted-entry-id")] + MaxDeletedEntryId, + + /// + /// The recorded first entry ID (Redis 7.0+). + /// + [AsciiHash("recorded-first-entry-id")] + RecordedFirstEntryId, + + /// + /// The total number of entries added (Redis 7.0+). + /// + [AsciiHash("entries-added")] + EntriesAdded, + + /// + /// IDMP duration in seconds (Redis 8.6+). + /// + [AsciiHash("idmp-duration")] + IdmpDuration, + + /// + /// IDMP max size (Redis 8.6+). + /// + [AsciiHash("idmp-maxsize")] + IdmpMaxsize, + + /// + /// Number of PIDs tracked (Redis 8.6+). + /// + [AsciiHash("pids-tracked")] + PidsTracked, + + /// + /// Number of IIDs tracked (Redis 8.6+). + /// + [AsciiHash("iids-tracked")] + IidsTracked, + + /// + /// Number of IIDs added (Redis 8.6+). + /// + [AsciiHash("iids-added")] + IidsAdded, + + /// + /// Number of duplicate IIDs (Redis 8.6+). + /// + [AsciiHash("iids-duplicates")] + IidsDuplicates, +} + +/// +/// Metadata and parsing methods for StreamInfoField. +/// +internal static partial class StreamInfoFieldMetadata +{ + [AsciiHash] + internal static partial bool TryParse(ReadOnlySpan value, out StreamInfoField field); +} diff --git a/src/StackExchange.Redis/ValueCondition.cs b/src/StackExchange.Redis/ValueCondition.cs index d61a2f00e..c5cf4bd5a 100644 --- a/src/StackExchange.Redis/ValueCondition.cs +++ b/src/StackExchange.Redis/ValueCondition.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO.Hashing; using System.Runtime.CompilerServices; +using RESPite; namespace StackExchange.Redis; diff --git a/src/StackExchange.Redis/VectorSetAddRequest.cs b/src/StackExchange.Redis/VectorSetAddRequest.cs index 987118c09..8428d6031 100644 --- a/src/StackExchange.Redis/VectorSetAddRequest.cs +++ b/src/StackExchange.Redis/VectorSetAddRequest.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; +using RESPite; namespace StackExchange.Redis; diff --git a/src/StackExchange.Redis/VectorSetInfo.cs b/src/StackExchange.Redis/VectorSetInfo.cs index c9277eae5..afbc3fece 100644 --- a/src/StackExchange.Redis/VectorSetInfo.cs +++ b/src/StackExchange.Redis/VectorSetInfo.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; +using RESPite; namespace StackExchange.Redis; diff --git a/src/StackExchange.Redis/VectorSetInfoField.cs b/src/StackExchange.Redis/VectorSetInfoField.cs new file mode 100644 index 000000000..1ed9266be --- /dev/null +++ b/src/StackExchange.Redis/VectorSetInfoField.cs @@ -0,0 +1,61 @@ +using System; +using RESPite; + +namespace StackExchange.Redis; + +/// +/// Represents fields in a VSET.INFO response. +/// +internal enum VectorSetInfoField +{ + /// + /// Unknown or unrecognized field. + /// + [AsciiHash("")] + Unknown = 0, + + /// + /// The size field. + /// + [AsciiHash("size")] + Size, + + /// + /// The vset-uid field. + /// + [AsciiHash("vset-uid")] + VsetUid, + + /// + /// The max-level field. + /// + [AsciiHash("max-level")] + MaxLevel, + + /// + /// The vector-dim field. + /// + [AsciiHash("vector-dim")] + VectorDim, + + /// + /// The quant-type field. + /// + [AsciiHash("quant-type")] + QuantType, + + /// + /// The hnsw-max-node-uid field. + /// + [AsciiHash("hnsw-max-node-uid")] + HnswMaxNodeUid, +} + +/// +/// Metadata and parsing methods for VectorSetInfoField. +/// +internal static partial class VectorSetInfoFieldMetadata +{ + [AsciiHash] + internal static partial bool TryParse(ReadOnlySpan value, out VectorSetInfoField field); +} diff --git a/src/StackExchange.Redis/VectorSetLink.cs b/src/StackExchange.Redis/VectorSetLink.cs index c18e8a95f..5d58a8d7f 100644 --- a/src/StackExchange.Redis/VectorSetLink.cs +++ b/src/StackExchange.Redis/VectorSetLink.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using RESPite; namespace StackExchange.Redis; diff --git a/src/StackExchange.Redis/VectorSetQuantization.cs b/src/StackExchange.Redis/VectorSetQuantization.cs index d78f4b34b..c7c5bf2e7 100644 --- a/src/StackExchange.Redis/VectorSetQuantization.cs +++ b/src/StackExchange.Redis/VectorSetQuantization.cs @@ -1,4 +1,6 @@ +using System; using System.Diagnostics.CodeAnalysis; +using RESPite; namespace StackExchange.Redis; @@ -11,20 +13,33 @@ public enum VectorSetQuantization /// /// Unknown or unrecognized quantization type. /// + [AsciiHash("")] Unknown = 0, /// /// No quantization (full precision). This maps to "NOQUANT" or "f32". /// + [AsciiHash("f32")] None = 1, /// /// 8-bit integer quantization (default). This maps to "Q8" or "int8". /// + [AsciiHash("int8")] Int8 = 2, /// /// Binary quantization. This maps to "BIN" or "bin". /// + [AsciiHash("bin")] Binary = 3, } + +/// +/// Metadata and parsing methods for VectorSetQuantization. +/// +internal static partial class VectorSetQuantizationMetadata +{ + [AsciiHash] + internal static partial bool TryParse(ReadOnlySpan value, out VectorSetQuantization quantization); +} diff --git a/src/StackExchange.Redis/VectorSetSimilaritySearchRequest.cs b/src/StackExchange.Redis/VectorSetSimilaritySearchRequest.cs index d0c0fd4cc..1343fd3f1 100644 --- a/src/StackExchange.Redis/VectorSetSimilaritySearchRequest.cs +++ b/src/StackExchange.Redis/VectorSetSimilaritySearchRequest.cs @@ -1,6 +1,7 @@ using System; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; +using RESPite; using VsimFlags = StackExchange.Redis.VectorSetSimilaritySearchMessage.VsimFlags; namespace StackExchange.Redis; diff --git a/src/StackExchange.Redis/VectorSetSimilaritySearchResult.cs b/src/StackExchange.Redis/VectorSetSimilaritySearchResult.cs index fd912898b..e16f91fdb 100644 --- a/src/StackExchange.Redis/VectorSetSimilaritySearchResult.cs +++ b/src/StackExchange.Redis/VectorSetSimilaritySearchResult.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using RESPite; namespace StackExchange.Redis; diff --git a/tests/RESPite.Tests/CycleBufferTests.cs b/tests/RESPite.Tests/CycleBufferTests.cs new file mode 100644 index 000000000..14dcd6f13 --- /dev/null +++ b/tests/RESPite.Tests/CycleBufferTests.cs @@ -0,0 +1,87 @@ +using System; +using RESPite.Buffers; +using Xunit; + +namespace RESPite.Tests; + +public class CycleBufferTests() +{ + public enum Timing + { + CommitEverythingBeforeDiscard, + CommitAfterFirstDiscard, + } + + [Theory] + [InlineData(Timing.CommitEverythingBeforeDiscard)] + [InlineData(Timing.CommitAfterFirstDiscard)] + public void CanDiscardSafely(Timing timing) + { + var buffer = CycleBuffer.Create(); + buffer.GetUncommittedSpan(10).Slice(0, 10).Fill(1); + Assert.Equal(0, buffer.GetCommittedLength()); + buffer.Commit(10); + Assert.Equal(10, buffer.GetCommittedLength()); + buffer.GetUncommittedSpan(15).Slice(0, 15).Fill(2); + + if (timing is Timing.CommitEverythingBeforeDiscard) buffer.Commit(15); + + Assert.True(buffer.TryGetFirstCommittedSpan(1, out var committed)); + switch (timing) + { + case Timing.CommitEverythingBeforeDiscard: + Assert.Equal(25, committed.Length); + for (int i = 0; i < 10; i++) + { + if (1 != committed[i]) + { + Assert.Fail($"committed[{i}]={committed[i]}"); + } + } + for (int i = 10; i < 25; i++) + { + if (2 != committed[i]) + { + Assert.Fail($"committed[{i}]={committed[i]}"); + } + } + break; + case Timing.CommitAfterFirstDiscard: + Assert.Equal(10, committed.Length); + for (int i = 0; i < committed.Length; i++) + { + if (1 != committed[i]) + { + Assert.Fail($"committed[{i}]={committed[i]}"); + } + } + break; + } + + buffer.DiscardCommitted(committed.Length); + Assert.Equal(0, buffer.GetCommittedLength()); + + // now (simulating concurrent) we commit the second span + if (timing is Timing.CommitAfterFirstDiscard) + { + buffer.Commit(15); + + Assert.Equal(15, buffer.GetCommittedLength()); + + // and we should be able to read those bytes + Assert.True(buffer.TryGetFirstCommittedSpan(1, out committed)); + Assert.Equal(15, committed.Length); + for (int i = 0; i < committed.Length; i++) + { + if (2 != committed[i]) + { + Assert.Fail($"committed[{i}]={committed[i]}"); + } + } + + buffer.DiscardCommitted(committed.Length); + } + + Assert.Equal(0, buffer.GetCommittedLength()); + } +} diff --git a/tests/RESPite.Tests/RESPite.Tests.csproj b/tests/RESPite.Tests/RESPite.Tests.csproj new file mode 100644 index 000000000..eb40683a7 --- /dev/null +++ b/tests/RESPite.Tests/RESPite.Tests.csproj @@ -0,0 +1,22 @@ + + + + net481;net8.0;net10.0 + enable + false + true + Exe + + + + + + + + + + + + + + diff --git a/tests/RESPite.Tests/RespReaderTests.cs b/tests/RESPite.Tests/RespReaderTests.cs new file mode 100644 index 000000000..505fa480c --- /dev/null +++ b/tests/RESPite.Tests/RespReaderTests.cs @@ -0,0 +1,1081 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Numerics; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using RESPite.Internal; +using RESPite.Messages; +using Xunit; +using Xunit.Sdk; +using Xunit.v3; + +namespace RESPite.Tests; + +public class RespReaderTests(ITestOutputHelper logger) +{ + public readonly struct RespPayload(string label, ReadOnlySequence payload, byte[] expected, bool? outOfBand, int count) + { + public override string ToString() => Label; + public string Label { get; } = label; + public ReadOnlySequence PayloadRaw { get; } = payload; + public int Length { get; } = CheckPayload(payload, expected, outOfBand, count); + private static int CheckPayload(scoped in ReadOnlySequence actual, byte[] expected, bool? outOfBand, int count) + { + Assert.Equal(expected.LongLength, actual.Length); + var pool = ArrayPool.Shared.Rent(expected.Length); + actual.CopyTo(pool); + bool isSame = pool.AsSpan(0, expected.Length).SequenceEqual(expected); + ArrayPool.Shared.Return(pool); + Assert.True(isSame, "Data mismatch"); + + // verify that the data exactly passes frame-scanning + long totalBytes = 0; + RespReader reader = new(actual); + while (count > 0) + { + RespScanState state = default; + Assert.True(state.TryRead(ref reader, out long bytesRead)); + totalBytes += bytesRead; + Assert.True(state.IsComplete, nameof(state.IsComplete)); + if (outOfBand.HasValue) + { + if (outOfBand.Value) + { + Assert.Equal(RespPrefix.Push, state.Prefix); + } + else + { + Assert.NotEqual(RespPrefix.Push, state.Prefix); + } + } + count--; + } + Assert.Equal(expected.Length, totalBytes); + reader.DemandEnd(); + return expected.Length; + } + + public RespReader Reader() => new(PayloadRaw); + } + + public sealed class RespAttribute : DataAttribute + { + public override bool SupportsDiscoveryEnumeration() => true; + + private readonly object _value; + public bool OutOfBand { get; set; } = false; + + private bool? EffectiveOutOfBand => Count == 1 ? OutOfBand : default(bool?); + public int Count { get; set; } = 1; + + public RespAttribute(string value) => _value = value; + public RespAttribute(params string[] values) => _value = values; + + public override ValueTask> GetData(MethodInfo testMethod, DisposalTracker disposalTracker) + => new(GetData(testMethod).ToArray()); + + public IEnumerable GetData(MethodInfo testMethod) + { + switch (_value) + { + case string s: + foreach (var item in GetVariants(s, EffectiveOutOfBand, Count)) + { + yield return new TheoryDataRow(item); + } + break; + case string[] arr: + foreach (string s in arr) + { + foreach (var item in GetVariants(s, EffectiveOutOfBand, Count)) + { + yield return new TheoryDataRow(item); + } + } + break; + } + } + + private static IEnumerable GetVariants(string value, bool? outOfBand, int count) + { + var bytes = Encoding.UTF8.GetBytes(value); + + // all in one + yield return new("Right-sized", new(bytes), bytes, outOfBand, count); + + var bigger = new byte[bytes.Length + 4]; + bytes.CopyTo(bigger.AsSpan(2, bytes.Length)); + bigger.AsSpan(0, 2).Fill(0xFF); + bigger.AsSpan(bytes.Length + 2, 2).Fill(0xFF); + + // all in one, oversized + yield return new("Oversized", new(bigger, 2, bytes.Length), bytes, outOfBand, count); + + // two-chunks + for (int i = 0; i <= bytes.Length; i++) + { + int offset = 2 + i; + var left = new Segment(new ReadOnlyMemory(bigger, 0, offset), null); + var right = new Segment(new ReadOnlyMemory(bigger, offset, bigger.Length - offset), left); + yield return new($"Split:{i}", new ReadOnlySequence(left, 2, right, right.Length - 2), bytes, outOfBand, count); + } + + // N-chunks + Segment head = new(new(bytes, 0, 1), null), tail = head; + for (int i = 1; i < bytes.Length; i++) + { + tail = new(new(bytes, i, 1), tail); + } + yield return new("Chunk-per-byte", new(head, 0, tail, 1), bytes, outOfBand, count); + } + } + + [Theory, Resp("$3\r\n128\r\n")] + public void HandleSplitTokens(RespPayload payload) + { + RespReader reader = payload.Reader(); + RespScanState scan = default; + bool readResult = scan.TryRead(ref reader, out _); + logger.WriteLine(scan.ToString()); + Assert.Equal(payload.Length, reader.BytesConsumed); + Assert.True(readResult); + } + + // the examples from https://github.com/redis/redis-specifications/blob/master/protocol/RESP3.md + [Theory, Resp("$11\r\nhello world\r\n", "$?\r\n;6\r\nhello \r\n;5\r\nworld\r\n;0\r\n")] + public void BlobString(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.BulkString); + Assert.True(reader.Is("hello world"u8)); + Assert.Equal("hello world", reader.ReadString()); + Assert.Equal("hello world", reader.ReadString(out var prefix)); + Assert.Equal("", prefix); +#if NET7_0_OR_GREATER + Assert.Equal("hello world", reader.ParseChars()); +#endif + /* interestingly, string does not implement IUtf8SpanParsable +#if NET8_0_OR_GREATER + Assert.Equal("hello world", reader.ParseBytes()); +#endif + */ + reader.DemandEnd(); + } + + [Theory, Resp("$0\r\n\r\n", "$?\r\n;0\r\n")] + public void EmptyBlobString(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.BulkString); + Assert.True(reader.Is(""u8)); + Assert.Equal("", reader.ReadString()); + reader.DemandEnd(); + } + + [Theory, Resp("+hello world\r\n")] + public void SimpleString(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.SimpleString); + Assert.True(reader.Is("hello world"u8)); + Assert.Equal("hello world", reader.ReadString()); + Assert.Equal("hello world", reader.ReadString(out var prefix)); + Assert.Equal("", prefix); + reader.DemandEnd(); + } + + [Theory, Resp("-ERR this is the error description\r\n")] + public void SimpleError_ImplicitErrors(RespPayload payload) + { + var ex = Assert.Throws(() => + { + var reader = payload.Reader(); + reader.MoveNext(); + }); + Assert.Equal("ERR this is the error description", ex.Message); + } + + [Theory, Resp("-ERR this is the error description\r\n")] + public void SimpleError_Careful(RespPayload payload) + { + var reader = payload.Reader(); + Assert.True(reader.TryMoveNext(checkError: false)); + Assert.Equal(RespPrefix.SimpleError, reader.Prefix); + Assert.True(reader.Is("ERR this is the error description"u8)); + Assert.Equal("ERR this is the error description", reader.ReadString()); + reader.DemandEnd(); + } + + [Theory, Resp(":1234\r\n")] + public void Number(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Integer); + Assert.True(reader.Is("1234"u8)); + Assert.Equal("1234", reader.ReadString()); + Assert.Equal(1234, reader.ReadInt32()); + Assert.Equal(1234D, reader.ReadDouble()); + Assert.Equal(1234M, reader.ReadDecimal()); +#if NET7_0_OR_GREATER + Assert.Equal(1234, reader.ParseChars()); + Assert.Equal(1234D, reader.ParseChars()); + Assert.Equal(1234M, reader.ParseChars()); +#endif +#if NET8_0_OR_GREATER + Assert.Equal(1234, reader.ParseBytes()); + Assert.Equal(1234D, reader.ParseBytes()); + Assert.Equal(1234M, reader.ParseBytes()); +#endif + reader.DemandEnd(); + } + + [Theory, Resp("_\r\n")] + public void Null(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Null); + Assert.True(reader.Is(""u8)); + Assert.Null(reader.ReadString()); + reader.DemandEnd(); + } + + [Theory, Resp("$-1\r\n")] + public void NullString(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.BulkString); + Assert.True(reader.IsNull); + Assert.Null(reader.ReadString()); + Assert.Equal(0, reader.ScalarLength()); + Assert.True(reader.Is(""u8)); + Assert.True(reader.ScalarIsEmpty()); + + var iterator = reader.ScalarChunks(); + Assert.False(iterator.MoveNext()); + iterator.MovePast(out reader); + reader.DemandEnd(); + } + + [Theory, Resp(",1.23\r\n")] + public void Double(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Double); + Assert.True(reader.Is("1.23"u8)); + Assert.Equal("1.23", reader.ReadString()); + Assert.Equal(1.23D, reader.ReadDouble()); + Assert.Equal(1.23M, reader.ReadDecimal()); + reader.DemandEnd(); + } + + [Theory, Resp(":10\r\n")] + public void Integer_Simple(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Integer); + Assert.True(reader.Is("10"u8)); + Assert.Equal("10", reader.ReadString()); + Assert.Equal(10, reader.ReadInt32()); + Assert.Equal(10D, reader.ReadDouble()); + Assert.Equal(10M, reader.ReadDecimal()); + reader.DemandEnd(); + } + + [Theory, Resp(",10\r\n")] + public void Double_Simple(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Double); + Assert.True(reader.Is("10"u8)); + Assert.Equal("10", reader.ReadString()); + Assert.Equal(10, reader.ReadInt32()); + Assert.Equal(10D, reader.ReadDouble()); + Assert.Equal(10M, reader.ReadDecimal()); + reader.DemandEnd(); + } + + [Theory, Resp(",inf\r\n")] + public void Double_Infinity(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Double); + Assert.True(reader.Is("inf"u8)); + Assert.Equal("inf", reader.ReadString()); + var val = reader.ReadDouble(); + Assert.True(double.IsInfinity(val)); + Assert.True(double.IsPositiveInfinity(val)); + reader.DemandEnd(); + } + + [Theory, Resp(",+inf\r\n")] + public void Double_PosInfinity(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Double); + Assert.True(reader.Is("+inf"u8)); + Assert.Equal("+inf", reader.ReadString()); + var val = reader.ReadDouble(); + Assert.True(double.IsInfinity(val)); + Assert.True(double.IsPositiveInfinity(val)); + reader.DemandEnd(); + } + + [Theory, Resp(",-inf\r\n")] + public void Double_NegInfinity(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Double); + Assert.True(reader.Is("-inf"u8)); + Assert.Equal("-inf", reader.ReadString()); + var val = reader.ReadDouble(); + Assert.True(double.IsInfinity(val)); + Assert.True(double.IsNegativeInfinity(val)); + reader.DemandEnd(); + } + + [Theory, Resp(",nan\r\n")] + public void Double_NaN(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Double); + Assert.True(reader.Is("nan"u8)); + Assert.Equal("nan", reader.ReadString()); + var val = reader.ReadDouble(); + Assert.True(double.IsNaN(val)); + reader.DemandEnd(); + } + + [Theory, Resp("#t\r\n")] + public void Boolean_T(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Boolean); + Assert.True(reader.ReadBoolean()); + reader.DemandEnd(); + } + + [Theory, Resp("#f\r\n")] + public void Boolean_F(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Boolean); + Assert.False(reader.ReadBoolean()); + reader.DemandEnd(); + } + + [Theory, Resp(":1\r\n")] + public void Boolean_1(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Integer); + Assert.True(reader.ReadBoolean()); + reader.DemandEnd(); + } + + [Theory, Resp(":0\r\n")] + public void Boolean_0(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Integer); + Assert.False(reader.ReadBoolean()); + reader.DemandEnd(); + } + + [Theory, Resp("!21\r\nSYNTAX invalid syntax\r\n", "!?\r\n;6\r\nSYNTAX\r\n;15\r\n invalid syntax\r\n;0\r\n")] + public void BlobError_ImplicitErrors(RespPayload payload) + { + var ex = Assert.Throws(() => + { + var reader = payload.Reader(); + reader.MoveNext(); + }); + Assert.Equal("SYNTAX invalid syntax", ex.Message); + } + + [Theory, Resp("!21\r\nSYNTAX invalid syntax\r\n", "!?\r\n;6\r\nSYNTAX\r\n;15\r\n invalid syntax\r\n;0\r\n")] + public void BlobError_Careful(RespPayload payload) + { + var reader = payload.Reader(); + Assert.True(reader.TryMoveNext(checkError: false)); + Assert.Equal(RespPrefix.BulkError, reader.Prefix); + Assert.True(reader.Is("SYNTAX invalid syntax"u8)); + Assert.Equal("SYNTAX invalid syntax", reader.ReadString()); + reader.DemandEnd(); + } + + [Theory, Resp("=15\r\ntxt:Some string\r\n", "=?\r\n;4\r\ntxt:\r\n;11\r\nSome string\r\n;0\r\n")] + public void VerbatimString(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.VerbatimString); + Assert.Equal("Some string", reader.ReadString()); + Assert.Equal("Some string", reader.ReadString(out var prefix)); + Assert.Equal("txt", prefix); + + Assert.Equal("Some string", reader.ReadString(out var prefix2)); + Assert.Same(prefix, prefix2); // check prefix recognized and reuse literal + reader.DemandEnd(); + } + + [Theory, Resp("(3492890328409238509324850943850943825024385\r\n")] + public void BigIntegers(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.BigInteger); + Assert.Equal("3492890328409238509324850943850943825024385", reader.ReadString()); +#if NET8_0_OR_GREATER + var actual = reader.ParseChars(chars => BigInteger.Parse(chars, CultureInfo.InvariantCulture)); + + var expected = BigInteger.Parse("3492890328409238509324850943850943825024385"); + Assert.Equal(expected, actual); +#endif + } + + [Theory, Resp("*3\r\n:1\r\n:2\r\n:3\r\n", "*?\r\n:1\r\n:2\r\n:3\r\n.\r\n")] + public void Array(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Array); + Assert.Equal(3, reader.AggregateLength()); + var iterator = reader.AggregateChildren(); + Assert.True(iterator.MoveNext(RespPrefix.Integer)); + Assert.Equal(1, iterator.Value.ReadInt32()); + iterator.Value.DemandEnd(); + + Assert.True(iterator.MoveNext(RespPrefix.Integer)); + Assert.Equal(2, iterator.Value.ReadInt32()); + iterator.Value.DemandEnd(); + + Assert.True(iterator.MoveNext(RespPrefix.Integer)); + Assert.Equal(3, iterator.Value.ReadInt32()); + iterator.Value.DemandEnd(); + + Assert.False(iterator.MoveNext(RespPrefix.Integer)); + iterator.MovePast(out reader); + reader.DemandEnd(); + + reader = payload.Reader(); + reader.MoveNext(RespPrefix.Array); + int[] arr = new int[reader.AggregateLength()]; + int i = 0; +#pragma warning disable SERDBG // warning about .Current vs .Value + foreach (var sub in reader.AggregateChildren()) +#pragma warning restore SERDBG + { + sub.Demand(RespPrefix.Integer); + arr[i++] = sub.ReadInt32(); + sub.DemandEnd(); + } + iterator.MovePast(out reader); + reader.DemandEnd(); + + Assert.Equal([1, 2, 3], arr); + } + + [Theory, Resp("*-1\r\n")] + public void NullArray(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Array); + Assert.True(reader.IsNull); + Assert.Equal(0, reader.AggregateLength()); + var iterator = reader.AggregateChildren(); + Assert.False(iterator.MoveNext()); + iterator.MovePast(out reader); + reader.DemandEnd(); + } + + [Theory, Resp("*2\r\n*3\r\n:1\r\n$5\r\nhello\r\n:2\r\n#f\r\n", "*?\r\n*?\r\n:1\r\n$5\r\nhello\r\n:2\r\n.\r\n#f\r\n.\r\n")] + public void NestedArray(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Array); + + Assert.Equal(2, reader.AggregateLength()); + + var iterator = reader.AggregateChildren(); + Assert.True(iterator.MoveNext(RespPrefix.Array)); + + Assert.Equal(3, iterator.Value.AggregateLength()); + var subIterator = iterator.Value.AggregateChildren(); + Assert.True(subIterator.MoveNext(RespPrefix.Integer)); + Assert.Equal(1, subIterator.Value.ReadInt64()); + subIterator.Value.DemandEnd(); + + Assert.True(subIterator.MoveNext(RespPrefix.BulkString)); + Assert.True(subIterator.Value.Is("hello"u8)); + subIterator.Value.DemandEnd(); + + Assert.True(subIterator.MoveNext(RespPrefix.Integer)); + Assert.Equal(2, subIterator.Value.ReadInt64()); + subIterator.Value.DemandEnd(); + + Assert.False(subIterator.MoveNext()); + + Assert.True(iterator.MoveNext(RespPrefix.Boolean)); + Assert.False(iterator.Value.ReadBoolean()); + iterator.Value.DemandEnd(); + + Assert.False(iterator.MoveNext()); + iterator.MovePast(out reader); + + reader.DemandEnd(); + } + + [Theory, Resp("%2\r\n+first\r\n:1\r\n+second\r\n:2\r\n", "%?\r\n+first\r\n:1\r\n+second\r\n:2\r\n.\r\n")] + public void Map(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Map); + + Assert.Equal(4, reader.AggregateLength()); + + var iterator = reader.AggregateChildren(); + + Assert.True(iterator.MoveNext(RespPrefix.SimpleString)); + Assert.True(iterator.Value.Is("first".AsSpan())); + iterator.Value.DemandEnd(); + + Assert.True(iterator.MoveNext(RespPrefix.Integer)); + Assert.Equal(1, iterator.Value.ReadInt32()); + iterator.Value.DemandEnd(); + + Assert.True(iterator.MoveNext(RespPrefix.SimpleString)); + Assert.True(iterator.Value.Is("second"u8)); + iterator.Value.DemandEnd(); + + Assert.True(iterator.MoveNext(RespPrefix.Integer)); + Assert.Equal(2, iterator.Value.ReadInt32()); + iterator.Value.DemandEnd(); + + Assert.False(iterator.MoveNext()); + + iterator.MovePast(out reader); + reader.DemandEnd(); + } + + [Theory, Resp("~5\r\n+orange\r\n+apple\r\n#t\r\n:100\r\n:999\r\n", "~?\r\n+orange\r\n+apple\r\n#t\r\n:100\r\n:999\r\n.\r\n")] + public void Set(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Set); + + Assert.Equal(5, reader.AggregateLength()); + + var iterator = reader.AggregateChildren(); + + Assert.True(iterator.MoveNext(RespPrefix.SimpleString)); + Assert.True(iterator.Value.Is("orange".AsSpan())); + iterator.Value.DemandEnd(); + + Assert.True(iterator.MoveNext(RespPrefix.SimpleString)); + Assert.True(iterator.Value.Is("apple"u8)); + iterator.Value.DemandEnd(); + + Assert.True(iterator.MoveNext(RespPrefix.Boolean)); + Assert.True(iterator.Value.ReadBoolean()); + iterator.Value.DemandEnd(); + + Assert.True(iterator.MoveNext(RespPrefix.Integer)); + Assert.Equal(100, iterator.Value.ReadInt32()); + iterator.Value.DemandEnd(); + + Assert.True(iterator.MoveNext(RespPrefix.Integer)); + Assert.Equal(999, iterator.Value.ReadInt32()); + iterator.Value.DemandEnd(); + + Assert.False(iterator.MoveNext()); + + iterator.MovePast(out reader); + reader.DemandEnd(); + } + + private sealed class TestAttributeReader : RespAttributeReader<(int Count, int Ttl, decimal A, decimal B)> + { + public override void Read(ref RespReader reader, ref (int Count, int Ttl, decimal A, decimal B) value) + { + value.Count += ReadKeyValuePairs(ref reader, ref value); + } + private TestAttributeReader() { } + public static readonly TestAttributeReader Instance = new(); + public static (int Count, int Ttl, decimal A, decimal B) Zero = (0, 0, 0, 0); + public override bool ReadKeyValuePair(scoped ReadOnlySpan key, ref RespReader reader, ref (int Count, int Ttl, decimal A, decimal B) value) + { + if (key.SequenceEqual("ttl"u8) && reader.IsScalar) + { + value.Ttl = reader.ReadInt32(); + } + else if (key.SequenceEqual("key-popularity"u8) && reader.IsAggregate) + { + ReadKeyValuePairs(ref reader, ref value); // recurse to process a/b below + } + else if (key.SequenceEqual("a"u8) && reader.IsScalar) + { + value.A = reader.ReadDecimal(); + } + else if (key.SequenceEqual("b"u8) && reader.IsScalar) + { + value.B = reader.ReadDecimal(); + } + else + { + return false; // not recognized + } + return true; // recognized + } + } + + [Theory, Resp( + "|1\r\n+key-popularity\r\n%2\r\n$1\r\na\r\n,0.1923\r\n$1\r\nb\r\n,0.0012\r\n*2\r\n:2039123\r\n:9543892\r\n", + "|1\r\n+key-popularity\r\n%2\r\n$1\r\na\r\n,0.1923\r\n$1\r\nb\r\n,0.0012\r\n*?\r\n:2039123\r\n:9543892\r\n.\r\n")] + public void AttributeRoot(RespPayload payload) + { + // ignore the attribute data + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Array); + Assert.Equal(2, reader.AggregateLength()); + var iterator = reader.AggregateChildren(); + + Assert.True(iterator.MoveNext(RespPrefix.Integer)); + Assert.Equal(2039123, iterator.Value.ReadInt32()); + iterator.Value.DemandEnd(); + + Assert.True(iterator.MoveNext(RespPrefix.Integer)); + Assert.Equal(9543892, iterator.Value.ReadInt32()); + iterator.Value.DemandEnd(); + + Assert.False(iterator.MoveNext()); + iterator.MovePast(out reader); + reader.DemandEnd(); + + // process the attribute data + var state = TestAttributeReader.Zero; + reader = payload.Reader(); + reader.MoveNext(RespPrefix.Array, TestAttributeReader.Instance, ref state); + Assert.Equal(1, state.Count); + Assert.Equal(0.1923M, state.A); + Assert.Equal(0.0012M, state.B); + state = TestAttributeReader.Zero; + + Assert.Equal(2, reader.AggregateLength()); + iterator = reader.AggregateChildren(); + + Assert.True(iterator.MoveNext(RespPrefix.Integer, TestAttributeReader.Instance, ref state)); + Assert.Equal(2039123, iterator.Value.ReadInt32()); + Assert.Equal(0, state.Count); + iterator.Value.DemandEnd(); + + Assert.True(iterator.MoveNext(RespPrefix.Integer, TestAttributeReader.Instance, ref state)); + Assert.Equal(9543892, iterator.Value.ReadInt32()); + Assert.Equal(0, state.Count); + iterator.Value.DemandEnd(); + + Assert.False(iterator.MoveNext()); + iterator.MovePast(out reader); + reader.DemandEnd(); + } + + [Theory, Resp("*3\r\n:1\r\n:2\r\n|1\r\n+ttl\r\n:3600\r\n:3\r\n", "*?\r\n:1\r\n:2\r\n|1\r\n+ttl\r\n:3600\r\n:3\r\n.\r\n")] + public void AttributeInner(RespPayload payload) + { + // ignore the attribute data + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Array); + Assert.Equal(3, reader.AggregateLength()); + var iterator = reader.AggregateChildren(); + + Assert.True(iterator.MoveNext(RespPrefix.Integer)); + Assert.Equal(1, iterator.Value.ReadInt32()); + iterator.Value.DemandEnd(); + + Assert.True(iterator.MoveNext(RespPrefix.Integer)); + Assert.Equal(2, iterator.Value.ReadInt32()); + iterator.Value.DemandEnd(); + + Assert.True(iterator.MoveNext(RespPrefix.Integer)); + Assert.Equal(3, iterator.Value.ReadInt32()); + iterator.Value.DemandEnd(); + + Assert.False(iterator.MoveNext()); + iterator.MovePast(out reader); + reader.DemandEnd(); + + // process the attribute data + var state = TestAttributeReader.Zero; + reader = payload.Reader(); + reader.MoveNext(RespPrefix.Array, TestAttributeReader.Instance, ref state); + Assert.Equal(0, state.Count); + Assert.Equal(3, reader.AggregateLength()); + iterator = reader.AggregateChildren(); + + Assert.True(iterator.MoveNext(RespPrefix.Integer, TestAttributeReader.Instance, ref state)); + Assert.Equal(0, state.Count); + Assert.Equal(1, iterator.Value.ReadInt32()); + iterator.Value.DemandEnd(); + + Assert.True(iterator.MoveNext(RespPrefix.Integer, TestAttributeReader.Instance, ref state)); + Assert.Equal(0, state.Count); + Assert.Equal(2, iterator.Value.ReadInt32()); + iterator.Value.DemandEnd(); + + Assert.True(iterator.MoveNext(RespPrefix.Integer, TestAttributeReader.Instance, ref state)); + Assert.Equal(1, state.Count); + Assert.Equal(3600, state.Ttl); + state = TestAttributeReader.Zero; // reset + Assert.Equal(3, iterator.Value.ReadInt32()); + iterator.Value.DemandEnd(); + + Assert.False(iterator.MoveNextRaw(TestAttributeReader.Instance, ref state)); + Assert.Equal(0, state.Count); + iterator.MovePast(out reader); + reader.DemandEnd(); + } + + [Theory, Resp(">3\r\n+message\r\n+somechannel\r\n+this is the message\r\n", OutOfBand = true)] + public void Push(RespPayload payload) + { + // ignore the attribute data + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Push); + Assert.Equal(3, reader.AggregateLength()); + var iterator = reader.AggregateChildren(); + + Assert.True(iterator.MoveNext(RespPrefix.SimpleString)); + Assert.True(iterator.Value.Is("message"u8)); + iterator.Value.DemandEnd(); + + Assert.True(iterator.MoveNext(RespPrefix.SimpleString)); + Assert.True(iterator.Value.Is("somechannel"u8)); + iterator.Value.DemandEnd(); + + Assert.True(iterator.MoveNext(RespPrefix.SimpleString)); + Assert.True(iterator.Value.Is("this is the message"u8)); + iterator.Value.DemandEnd(); + + Assert.False(iterator.MoveNext()); + iterator.MovePast(out reader); + reader.DemandEnd(); + } + + [Theory, Resp(">3\r\n+message\r\n+somechannel\r\n+this is the message\r\n$9\r\nGet-Reply\r\n", Count = 2)] + public void PushThenGetReply(RespPayload payload) + { + // ignore the attribute data + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Push); + Assert.Equal(3, reader.AggregateLength()); + var iterator = reader.AggregateChildren(); + + Assert.True(iterator.MoveNext(RespPrefix.SimpleString)); + Assert.True(iterator.Value.Is("message"u8)); + iterator.Value.DemandEnd(); + + Assert.True(iterator.MoveNext(RespPrefix.SimpleString)); + Assert.True(iterator.Value.Is("somechannel"u8)); + iterator.Value.DemandEnd(); + + Assert.True(iterator.MoveNext(RespPrefix.SimpleString)); + Assert.True(iterator.Value.Is("this is the message"u8)); + iterator.Value.DemandEnd(); + + Assert.False(iterator.MoveNext()); + iterator.MovePast(out reader); + + reader.MoveNext(RespPrefix.BulkString); + Assert.True(reader.Is("Get-Reply"u8)); + reader.DemandEnd(); + } + + [Theory, Resp("$9\r\nGet-Reply\r\n>3\r\n+message\r\n+somechannel\r\n+this is the message\r\n", Count = 2)] + public void GetReplyThenPush(RespPayload payload) + { + // ignore the attribute data + var reader = payload.Reader(); + + reader.MoveNext(RespPrefix.BulkString); + Assert.True(reader.Is("Get-Reply"u8)); + + reader.MoveNext(RespPrefix.Push); + Assert.Equal(3, reader.AggregateLength()); + var iterator = reader.AggregateChildren(); + + Assert.True(iterator.MoveNext(RespPrefix.SimpleString)); + Assert.True(iterator.Value.Is("message"u8)); + iterator.Value.DemandEnd(); + + Assert.True(iterator.MoveNext(RespPrefix.SimpleString)); + Assert.True(iterator.Value.Is("somechannel"u8)); + iterator.Value.DemandEnd(); + + Assert.True(iterator.MoveNext(RespPrefix.SimpleString)); + Assert.True(iterator.Value.Is("this is the message"u8)); + iterator.Value.DemandEnd(); + + Assert.False(iterator.MoveNext()); + iterator.MovePast(out reader); + + reader.DemandEnd(); + } + + [Theory, Resp("*0\r\n$4\r\npass\r\n", "*1\r\n+ok\r\n$4\r\npass\r\n", "*-1\r\n$4\r\npass\r\n", "*?\r\n.\r\n$4\r\npass\r\n", Count = 2)] + public void ArrayThenString(RespPayload payload) + { + var reader = payload.Reader(); + Assert.True(reader.TryMoveNext(RespPrefix.Array)); + reader.SkipChildren(); + + Assert.True(reader.TryMoveNext(RespPrefix.BulkString)); + Assert.True(reader.Is("pass"u8)); + + reader.DemandEnd(); + + // and the same using child iterator + reader = payload.Reader(); + Assert.True(reader.TryMoveNext(RespPrefix.Array)); + var iterator = reader.AggregateChildren(); + iterator.MovePast(out reader); + + Assert.True(reader.TryMoveNext(RespPrefix.BulkString)); + Assert.True(reader.Is("pass"u8)); + + reader.DemandEnd(); + } + + // Tests for ScalarLengthIs + [Theory, Resp("$-1\r\n")] // null bulk string + public void ScalarLengthIs_NullBulkString(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.BulkString); + Assert.True(reader.ScalarLengthIs(0)); + Assert.False(reader.ScalarLengthIs(1)); + Assert.False(reader.ScalarLengthIs(5)); + reader.DemandEnd(); + } + + // Note: Null prefix (_\r\n) is tested in the existing Null() test above + [Theory, Resp("$0\r\n\r\n", "$?\r\n;0\r\n")] // empty scalar (simple and streaming) + public void ScalarLengthIs_Empty(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.BulkString); + Assert.True(reader.ScalarLengthIs(0)); + Assert.False(reader.ScalarLengthIs(1)); + Assert.False(reader.ScalarLengthIs(5)); + reader.DemandEnd(); + } + + [Theory, Resp("$5\r\nhello\r\n")] // simple scalar + public void ScalarLengthIs_Simple(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.BulkString); + Assert.True(reader.ScalarLengthIs(5)); + Assert.False(reader.ScalarLengthIs(0)); + Assert.False(reader.ScalarLengthIs(4)); + Assert.False(reader.ScalarLengthIs(6)); + Assert.False(reader.ScalarLengthIs(10)); + reader.DemandEnd(); + } + + [Theory, Resp("$?\r\n;2\r\nhe\r\n;3\r\nllo\r\n;0\r\n")] // streaming scalar + public void ScalarLengthIs_Streaming(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.BulkString); + Assert.True(reader.ScalarLengthIs(5)); + Assert.False(reader.ScalarLengthIs(0)); + Assert.False(reader.ScalarLengthIs(2)); // short-circuit: stops early + Assert.False(reader.ScalarLengthIs(3)); // short-circuit: stops early + Assert.False(reader.ScalarLengthIs(6)); // short-circuit: stops early + Assert.False(reader.ScalarLengthIs(10)); // short-circuit: stops early + reader.DemandEnd(); + } + + [Fact] // streaming scalar - verify short-circuiting stops before reading malformed data + public void ScalarLengthIs_Streaming_ShortCircuits() + { + // Streaming scalar: 2 bytes "he", then 3 bytes "llo", then 1 byte "X", then MALFORMED + // To check if length == N, we need to read N+1 bytes to verify there isn't more + // So malformed data must come AFTER the N+1 threshold + var data = "$?\r\n;2\r\nhe\r\n;3\r\nllo\r\n;1\r\nX\r\nMALFORMED"u8.ToArray(); + var reader = new RespReader(new ReadOnlySequence(data)); + reader.MoveNext(RespPrefix.BulkString); + + // When checking length < 6, we read up to 6 bytes (he+llo+X), see 6 > expected, stop + Assert.False(reader.ScalarLengthIs(0)); // reads "he" (2), 2 > 0, stops before "llo" + Assert.False(reader.ScalarLengthIs(2)); // reads "he" (2), "llo" (5 total), 5 > 2, stops before "X" + Assert.False(reader.ScalarLengthIs(4)); // reads "he" (2), "llo" (5 total), 5 > 4, stops before "X" + Assert.False(reader.ScalarLengthIs(5)); // reads "he" (2), "llo" (5), "X" (6 total), 6 > 5, stops before MALFORMED + + // All of the above should succeed without hitting MALFORMED because we short-circuit + } + + [Fact] // streaming scalar - verify TryGetSpan fails and Buffer works correctly + public void StreamingScalar_BufferPartial() + { + // 32 bytes total: "abcdefgh" (8) + "ijklmnop" (8) + "qrstuvwx" (8) + "yz012345" (8) + "6789" (4) + var data = "$?\r\n;8\r\nabcdefgh\r\n;8\r\nijklmnop\r\n;8\r\nqrstuvwx\r\n;8\r\nyz012345\r\n;4\r\n6789\r\n;0\r\n"u8.ToArray(); + var reader = new RespReader(new ReadOnlySequence(data)); + reader.MoveNext(RespPrefix.BulkString); + + Assert.True(reader.IsScalar); + Assert.False(reader.TryGetSpan(out _)); // Should fail - data is non-contiguous + + // Buffer should fetch just the first 16 bytes + Span buffer = stackalloc byte[16]; + var buffered = reader.Buffer(buffer); + Assert.Equal(16, buffered.Length); + Assert.True(buffered.SequenceEqual("abcdefghijklmnop"u8)); + } + + [Theory, Resp("+hello\r\n")] // simple string + public void ScalarLengthIs_SimpleString(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.SimpleString); + Assert.True(reader.ScalarLengthIs(5)); + Assert.False(reader.ScalarLengthIs(0)); + Assert.False(reader.ScalarLengthIs(4)); + reader.DemandEnd(); + } + + // Tests for AggregateLengthIs + [Theory, Resp("*-1\r\n")] // null array + public void AggregateLengthIs_NullArray(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Array); + Assert.True(reader.IsNull); + // Note: AggregateLength() would throw on null, but AggregateLengthIs should handle it + reader.DemandEnd(); + } + + [Theory, Resp("*0\r\n", "*?\r\n.\r\n")] // empty array (simple and streaming) + public void AggregateLengthIs_Empty(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Array); + Assert.True(reader.AggregateLengthIs(0)); + Assert.False(reader.AggregateLengthIs(1)); + Assert.False(reader.AggregateLengthIs(3)); + reader.SkipChildren(); + reader.DemandEnd(); + } + + [Theory, Resp("*3\r\n:1\r\n:2\r\n:3\r\n")] // simple array + public void AggregateLengthIs_Simple(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Array); + Assert.True(reader.AggregateLengthIs(3)); + Assert.False(reader.AggregateLengthIs(0)); + Assert.False(reader.AggregateLengthIs(2)); + Assert.False(reader.AggregateLengthIs(4)); + Assert.False(reader.AggregateLengthIs(10)); + reader.SkipChildren(); + reader.DemandEnd(); + } + + [Theory, Resp("*?\r\n:1\r\n:2\r\n:3\r\n.\r\n")] // streaming array + public void AggregateLengthIs_Streaming(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Array); + Assert.True(reader.AggregateLengthIs(3)); + Assert.False(reader.AggregateLengthIs(0)); + Assert.False(reader.AggregateLengthIs(2)); // short-circuit: stops early + Assert.False(reader.AggregateLengthIs(4)); // short-circuit: stops early + Assert.False(reader.AggregateLengthIs(10)); // short-circuit: stops early + reader.SkipChildren(); + reader.DemandEnd(); + } + + [Fact] // streaming array - verify short-circuiting works even with extra data present + public void AggregateLengthIs_Streaming_ShortCircuits() + { + // Streaming array: 3 elements (:1, :2, :3), then extra elements + // Short-circuiting means we can return false without reading all elements + var data = "*?\r\n:1\r\n:2\r\n:3\r\n:999\r\n:888\r\n.\r\n"u8.ToArray(); + var reader = new RespReader(new ReadOnlySequence(data)); + reader.MoveNext(RespPrefix.Array); + + // These should all return false via short-circuiting + // (we know the answer before reading all elements) + Assert.False(reader.AggregateLengthIs(0)); // can tell after 1 element + Assert.False(reader.AggregateLengthIs(2)); // can tell after 3 elements + Assert.False(reader.AggregateLengthIs(4)); // can tell after 4 elements (count > expected) + Assert.False(reader.AggregateLengthIs(10)); // can tell after 4 elements (count > expected) + + // The actual length is 5 (:1, :2, :3, :999, :888) + Assert.True(reader.AggregateLengthIs(5)); + } + + [Fact] // streaming array - verify short-circuiting stops before reading malformed data + public void AggregateLengthIs_Streaming_MalformedAfterShortCircuit() + { + // Streaming array: 3 elements (:1, :2, :3), then :4, then MALFORMED + // To check if length == N, we need to read N+1 elements to verify there isn't more + // So malformed data must come AFTER the N+1 threshold + var data = "*?\r\n:1\r\n:2\r\n:3\r\n:4\r\nGARBAGE_NOT_A_VALID_ELEMENT"u8.ToArray(); + var reader = new RespReader(new ReadOnlySequence(data)); + reader.MoveNext(RespPrefix.Array); + + // When checking length < 4, we read up to 4 elements, see 4 > expected, stop + Assert.False(reader.AggregateLengthIs(0)); // reads :1 (1 element), 1 > 0, stops before :2 + Assert.False(reader.AggregateLengthIs(2)); // reads :1, :2, :3 (3 elements), 3 > 2, stops before :4 + Assert.False(reader.AggregateLengthIs(3)); // reads :1, :2, :3, :4 (4 elements), 4 > 3, stops before MALFORMED + + // All of the above should succeed without hitting MALFORMED because we short-circuit + } + + [Theory, Resp("%2\r\n+first\r\n:1\r\n+second\r\n:2\r\n", "%?\r\n+first\r\n:1\r\n+second\r\n:2\r\n.\r\n")] // map (simple and streaming) + public void AggregateLengthIs_Map(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Map); + // Map length is doubled (2 pairs = 4 elements) + Assert.True(reader.AggregateLengthIs(4)); + Assert.False(reader.AggregateLengthIs(0)); + Assert.False(reader.AggregateLengthIs(2)); + Assert.False(reader.AggregateLengthIs(3)); + Assert.False(reader.AggregateLengthIs(5)); + reader.SkipChildren(); + reader.DemandEnd(); + } + + [Theory, Resp("~5\r\n+orange\r\n+apple\r\n#t\r\n:100\r\n:999\r\n", "~?\r\n+orange\r\n+apple\r\n#t\r\n:100\r\n:999\r\n.\r\n")] // set (simple and streaming) + public void AggregateLengthIs_Set(RespPayload payload) + { + var reader = payload.Reader(); + reader.MoveNext(RespPrefix.Set); + Assert.True(reader.AggregateLengthIs(5)); + Assert.False(reader.AggregateLengthIs(0)); + Assert.False(reader.AggregateLengthIs(4)); + Assert.False(reader.AggregateLengthIs(6)); + reader.SkipChildren(); + reader.DemandEnd(); + } + + private sealed class Segment : ReadOnlySequenceSegment + { + public override string ToString() => RespConstants.UTF8.GetString(Memory.Span) + .Replace("\r", "\\r").Replace("\n", "\\n"); + + public Segment(ReadOnlyMemory value, Segment? head) + { + Memory = value; + if (head is not null) + { + RunningIndex = head.RunningIndex + head.Memory.Length; + head.Next = this; + } + } + public bool IsEmpty => Memory.IsEmpty; + public int Length => Memory.Length; + } +} diff --git a/tests/RESPite.Tests/RespScannerTests.cs b/tests/RESPite.Tests/RespScannerTests.cs new file mode 100644 index 000000000..0028f0b3a --- /dev/null +++ b/tests/RESPite.Tests/RespScannerTests.cs @@ -0,0 +1,18 @@ +using RESPite.Messages; +using Xunit; + +namespace RESPite.Tests; + +public class RespScannerTests +{ + [Fact] + public void ScanNull() + { + RespScanState scanner = default; + Assert.True(scanner.TryRead("_\r\n"u8, out var consumed)); + + Assert.Equal(3, consumed); + Assert.Equal(3, scanner.TotalBytes); + Assert.Equal(RespPrefix.Null, scanner.Prefix); + } +} diff --git a/tests/RESPite.Tests/TestDuplexStream.cs b/tests/RESPite.Tests/TestDuplexStream.cs new file mode 100644 index 000000000..ee1cae2c7 --- /dev/null +++ b/tests/RESPite.Tests/TestDuplexStream.cs @@ -0,0 +1,229 @@ +using System; +using System.Buffers; +using System.IO; +using System.IO.Pipelines; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace RESPite.Tests; + +/// +/// A controllable duplex stream for testing Redis protocol interactions. +/// Captures outbound data (client-to-redis) and allows controlled inbound data (redis-to-client). +/// +public sealed class TestDuplexStream : Stream +{ + private static readonly PipeOptions s_pipeOptions = new(useSynchronizationContext: false); + + private readonly MemoryStream _outbound; + private readonly Pipe _inbound; + private readonly Stream _inboundStream; + + public TestDuplexStream() + { + _outbound = new MemoryStream(); + _inbound = new Pipe(s_pipeOptions); + _inboundStream = _inbound.Reader.AsStream(); + } + + /// + /// Gets the data that has been written to the stream (client-to-redis). + /// + public ReadOnlySpan GetOutboundData() + { + if (_outbound.TryGetBuffer(out var buffer)) + { + return buffer.AsSpan(); + } + return _outbound.GetBuffer().AsSpan(0, (int)_outbound.Length); + } + + /// + /// Clears the outbound data buffer. + /// + public void FlushOutboundData() + { + _outbound.Position = 0; + _outbound.SetLength(0); + } + + /// + /// Adds data to the inbound buffer (redis-to-client) that will be available for reading. + /// + public async ValueTask AddInboundAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) + { + await _inbound.Writer.WriteAsync(data, cancellationToken).ConfigureAwait(false); + await _inbound.Writer.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Adds data to the inbound buffer (redis-to-client) that will be available for reading. + /// Supports the "return pending.IsCompletedSynchronously ? default : AwaitAsync(pending)" pattern. + /// + public ValueTask AddInboundAsync(ReadOnlySpan data, CancellationToken cancellationToken = default) + { + // Use the Write extension method to write the span synchronously + _inbound.Writer.Write(data); + + // Flush and return based on completion status + var flushPending = _inbound.Writer.FlushAsync(cancellationToken); + return flushPending.IsCompletedSuccessfully ? default : AwaitFlushAsync(flushPending); + + static async ValueTask AwaitFlushAsync(ValueTask flushPending) + { + await flushPending.ConfigureAwait(false); + } + } + + /// + /// Adds UTF8-encoded string data to the inbound buffer (redis-to-client) that will be available for reading. + /// Uses stack allocation for small strings (≤256 bytes) and ArrayPool for larger strings. + /// Supports the "return pending.IsCompletedSynchronously ? default : AwaitAsync(pending)" pattern. + /// + public ValueTask AddInboundAsync(string data, CancellationToken cancellationToken = default) + { + const int StackAllocThreshold = 256; + + // Get the max byte count for UTF8 encoding + var maxByteCount = Encoding.UTF8.GetMaxByteCount(data.Length); + + if (maxByteCount <= StackAllocThreshold) + { + // Use stack allocation for small strings + Span buffer = stackalloc byte[maxByteCount]; + var actualByteCount = Encoding.UTF8.GetBytes(data, buffer); + _inbound.Writer.Write(buffer.Slice(0, actualByteCount)); + } + else + { + // Use ArrayPool for larger strings + var buffer = ArrayPool.Shared.Rent(maxByteCount); + try + { + var actualByteCount = Encoding.UTF8.GetBytes(data, buffer); + _inbound.Writer.Write(buffer.AsSpan(0, actualByteCount)); + } + finally + { + ArrayPool.Shared.Return(buffer); // can't have been captured during write, because span + } + } + + // Flush and return based on completion status + var flushPending = _inbound.Writer.FlushAsync(cancellationToken); + return flushPending.IsCompletedSuccessfully ? default : AwaitFlushAsync(flushPending); + + static async ValueTask AwaitFlushAsync(ValueTask flushPending) + { + await flushPending.ConfigureAwait(false); + } + } + + /// + /// Completes the inbound stream, signaling no more data will be written. + /// + public void CompleteInbound() + { + _inbound.Writer.Complete(); + } + + // Stream implementation - Read operations proxy to the inbound stream + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + return _inboundStream.Read(buffer, offset, count); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return _inboundStream.ReadAsync(buffer, offset, count, cancellationToken); + } + +#if NETCOREAPP || NETSTANDARD2_1_OR_GREATER + public override int Read(Span buffer) + { + return _inboundStream.Read(buffer); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + return _inboundStream.ReadAsync(buffer, cancellationToken); + } +#endif + + // Stream implementation - Write operations capture to the outbound stream + public override void Write(byte[] buffer, int offset, int count) + { + _outbound.Write(buffer, offset, count); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return _outbound.WriteAsync(buffer, offset, count, cancellationToken); + } + +#if NETCOREAPP || NETSTANDARD2_1_OR_GREATER + public override void Write(ReadOnlySpan buffer) + { + _outbound.Write(buffer); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + return _outbound.WriteAsync(buffer, cancellationToken); + } +#endif + + public override void Flush() + { + _outbound.Flush(); + } + + public override Task FlushAsync(CancellationToken cancellationToken) + { + return _outbound.FlushAsync(cancellationToken); + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inbound.Writer.Complete(); + _inbound.Reader.Complete(); + _inboundStream.Dispose(); + _outbound.Dispose(); + } + base.Dispose(disposing); + } + +#if NETCOREAPP || NETSTANDARD2_1_OR_GREATER + public override async ValueTask DisposeAsync() + { + _inbound.Writer.Complete(); + _inbound.Reader.Complete(); + await _inboundStream.DisposeAsync().ConfigureAwait(false); + await _outbound.DisposeAsync().ConfigureAwait(false); + await base.DisposeAsync().ConfigureAwait(false); + } +#endif +} diff --git a/tests/StackExchange.Redis.Benchmarks/FastHashBenchmarks.cs b/tests/StackExchange.Redis.Benchmarks/AsciiHashBenchmarks.cs similarity index 64% rename from tests/StackExchange.Redis.Benchmarks/FastHashBenchmarks.cs rename to tests/StackExchange.Redis.Benchmarks/AsciiHashBenchmarks.cs index 78877f163..57677f705 100644 --- a/tests/StackExchange.Redis.Benchmarks/FastHashBenchmarks.cs +++ b/tests/StackExchange.Redis.Benchmarks/AsciiHashBenchmarks.cs @@ -3,17 +3,19 @@ using System.Collections.Generic; using System.Text; using BenchmarkDotNet.Attributes; +using RESPite; namespace StackExchange.Redis.Benchmarks; -[Config(typeof(CustomConfig))] -public class FastHashBenchmarks +// [Config(typeof(CustomConfig))] +[ShortRunJob, MemoryDiagnoser] +public class AsciiHashBenchmarks { - private const string SharedString = "some-typical-data-for-comparisons"; + private const string SharedString = "some-typical-data-for-comparisons-that-needs-to-be-at-least-64-characters"; private static readonly byte[] SharedUtf8; private static readonly ReadOnlySequence SharedMultiSegment; - static FastHashBenchmarks() + static AsciiHashBenchmarks() { SharedUtf8 = Encoding.UTF8.GetBytes(SharedString); @@ -47,15 +49,16 @@ public void Setup() _sourceBytes = SharedUtf8.AsMemory(0, Size); _sourceMultiSegmentBytes = SharedMultiSegment.Slice(0, Size); -#pragma warning disable CS0618 // Type or member is obsolete var bytes = _sourceBytes.Span; - var expected = FastHash.Hash64Fallback(bytes); + var expected = AsciiHash.HashCS(bytes); - Assert(bytes.Hash64(), nameof(FastHash.Hash64)); - Assert(FastHash.Hash64Unsafe(bytes), nameof(FastHash.Hash64Unsafe)); -#pragma warning restore CS0618 // Type or member is obsolete - Assert(SingleSegmentBytes.Hash64(), nameof(FastHash.Hash64) + " (single segment)"); - Assert(_sourceMultiSegmentBytes.Hash64(), nameof(FastHash.Hash64) + " (multi segment)"); + Assert(AsciiHash.HashCS(bytes), nameof(AsciiHash.HashCS) + ":byte"); + Assert(AsciiHash.HashCS(_sourceString.AsSpan()), nameof(AsciiHash.HashCS) + ":char"); + + /* + Assert(AsciiHash.HashCS(SingleSegmentBytes), nameof(AsciiHash.HashCS) + " (single segment)"); + Assert(AsciiHash.HashCS(_sourceMultiSegmentBytes), nameof(AsciiHash.HashCS) + " (multi segment)"); + */ void Assert(long actual, string name) { @@ -69,71 +72,78 @@ void Assert(long actual, string name) [ParamsSource(nameof(Sizes))] public int Size { get; set; } = 7; - public IEnumerable Sizes => [0, 1, 2, 3, 4, 5, 6, 7, 8, 16]; + public IEnumerable Sizes => [0, 1, 2, 3, 4, 5, 6, 7, 8, 16, 64]; private const int OperationsPerInvoke = 1024; - [Benchmark(OperationsPerInvoke = OperationsPerInvoke, Baseline = true)] - public void String() + // [Benchmark(OperationsPerInvoke = OperationsPerInvoke, Baseline = true)] + public int StringGetHashCode() { + int hash = 0; var val = _sourceString; for (int i = 0; i < OperationsPerInvoke; i++) { - _ = val.GetHashCode(); + hash = val.GetHashCode(); } - } - [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] - public void Hash64() - { - var val = _sourceBytes.Span; - for (int i = 0; i < OperationsPerInvoke; i++) - { - _ = val.Hash64(); - } + return hash; } + [BenchmarkCategory("byte")] [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] - public void Hash64Unsafe() + public long HashCS_B() { + long hash = 0; var val = _sourceBytes.Span; for (int i = 0; i < OperationsPerInvoke; i++) { -#pragma warning disable CS0618 // Type or member is obsolete - _ = FastHash.Hash64Unsafe(val); -#pragma warning restore CS0618 // Type or member is obsolete + hash = AsciiHash.HashCS(val); } + + return hash; } + [BenchmarkCategory("char")] [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] - public void Hash64Fallback() + public long HashCS_C() { - var val = _sourceBytes.Span; + long hash = 0; + var val = _sourceString.AsSpan(); for (int i = 0; i < OperationsPerInvoke; i++) { #pragma warning disable CS0618 // Type or member is obsolete - _ = FastHash.Hash64Fallback(val); + hash = AsciiHash.HashCS(val); #pragma warning restore CS0618 // Type or member is obsolete } + + return hash; } - [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] - public void Hash64_SingleSegment() + /* + // [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] + public long Hash64_SingleSegment() { + long hash = 0; var val = SingleSegmentBytes; for (int i = 0; i < OperationsPerInvoke; i++) { - _ = val.Hash64(); + hash = AsciiHash.HashCS(val); } + + return hash; } - [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] - public void Hash64_MultiSegment() + // [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] + public long Hash64_MultiSegment() { + long hash = 0; var val = _sourceMultiSegmentBytes; for (int i = 0; i < OperationsPerInvoke; i++) { - _ = val.Hash64(); + hash = AsciiHash.HashCS(val); } + + return hash; } + */ } diff --git a/tests/StackExchange.Redis.Benchmarks/AsciiHashSwitch.cs b/tests/StackExchange.Redis.Benchmarks/AsciiHashSwitch.cs new file mode 100644 index 000000000..2409362ce --- /dev/null +++ b/tests/StackExchange.Redis.Benchmarks/AsciiHashSwitch.cs @@ -0,0 +1,517 @@ +using System; +using System.Text; +using BenchmarkDotNet.Attributes; +using RESPite; +// ReSharper disable InconsistentNaming +// ReSharper disable ArrangeTypeMemberModifiers +// ReSharper disable MemberCanBePrivate.Local +#pragma warning disable SA1300, SA1134, CS8981, SA1400 +namespace StackExchange.Redis.Benchmarks; + +[ShortRunJob, MemoryDiagnoser] +public class AsciiHashSwitch +{ + // conclusion: it doesn't matter; switch on the hash or length is fine, just: remember to do the Is check + // CS vs CI: CI misses are cheap, because of the hash fail; CI hits of values <= 8 characters are cheap if + // it turns out to be a CS match, because of the CS hash check which can cheaply test CS equality; CI inequality + // and CI equality over 8 characters has a bit more overhead, but still fine + public enum Field + { + key, + abc, + port, + test, + tracking_active, + sample_ratio, + selected_slots, + all_commands_all_slots_us, + all_commands_selected_slots_us, + sampled_command_selected_slots_us, + sampled_commands_selected_slots_us, + net_bytes_all_commands_all_slots, + net_bytes_all_commands_selected_slots, + net_bytes_sampled_commands_selected_slots, + collection_start_time_unix_ms, + collection_duration_ms, + collection_duration_us, + total_cpu_time_user_ms, + total_cpu_time_user_us, + total_cpu_time_sys_ms, + total_cpu_time_sys_us, + total_net_bytes, + by_cpu_time_us, + by_net_bytes, + + Unknown = -1, + } + + private byte[] _bytes = []; + [GlobalSetup] + public void Init() => _bytes = Encoding.UTF8.GetBytes(Value); + + public static string[] GetValues() => + [ + key.Text, + abc.Text, + port.Text, + test.Text, + tracking_active.Text, + sample_ratio.Text, + selected_slots.Text, + all_commands_all_slots_us.Text, + net_bytes_sampled_commands_selected_slots.Text, + total_cpu_time_sys_us.Text, + total_net_bytes.Text, + by_cpu_time_us.Text, + by_net_bytes.Text, + "miss", + "PORT", + "much longer miss", + ]; + + [ParamsSource(nameof(GetValues))] + public string Value { get; set; } = ""; + + [Benchmark] + public Field SwitchOnHash() + { + ReadOnlySpan span = _bytes; + var hash = AsciiHash.HashCS(span); + return hash switch + { + key.HashCS when key.IsCS(hash, span) => Field.key, + abc.HashCS when abc.IsCS(hash, span) => Field.abc, + port.HashCS when port.IsCS(hash, span) => Field.port, + test.HashCS when test.IsCS(hash, span) => Field.test, + tracking_active.HashCS when tracking_active.IsCS(hash, span) => Field.tracking_active, + sample_ratio.HashCS when sample_ratio.IsCS(hash, span) => Field.sample_ratio, + selected_slots.HashCS when selected_slots.IsCS(hash, span) => Field.selected_slots, + all_commands_all_slots_us.HashCS when all_commands_all_slots_us.IsCS(hash, span) => Field.all_commands_all_slots_us, + all_commands_selected_slots_us.HashCS when all_commands_selected_slots_us.IsCS(hash, span) => Field.all_commands_selected_slots_us, + sampled_command_selected_slots_us.HashCS when sampled_command_selected_slots_us.IsCS(hash, span) => Field.sampled_command_selected_slots_us, + sampled_commands_selected_slots_us.HashCS when sampled_commands_selected_slots_us.IsCS(hash, span) => Field.sampled_commands_selected_slots_us, + net_bytes_all_commands_all_slots.HashCS when net_bytes_all_commands_all_slots.IsCS(hash, span) => Field.net_bytes_all_commands_all_slots, + net_bytes_all_commands_selected_slots.HashCS when net_bytes_all_commands_selected_slots.IsCS(hash, span) => Field.net_bytes_all_commands_selected_slots, + net_bytes_sampled_commands_selected_slots.HashCS when net_bytes_sampled_commands_selected_slots.IsCS(hash, span) => Field.net_bytes_sampled_commands_selected_slots, + collection_start_time_unix_ms.HashCS when collection_start_time_unix_ms.IsCS(hash, span) => Field.collection_start_time_unix_ms, + collection_duration_ms.HashCS when collection_duration_ms.IsCS(hash, span) => Field.collection_duration_ms, + collection_duration_us.HashCS when collection_duration_us.IsCS(hash, span) => Field.collection_duration_us, + total_cpu_time_user_ms.HashCS when total_cpu_time_user_ms.IsCS(hash, span) => Field.total_cpu_time_user_ms, + total_cpu_time_user_us.HashCS when total_cpu_time_user_us.IsCS(hash, span) => Field.total_cpu_time_user_us, + total_cpu_time_sys_ms.HashCS when total_cpu_time_sys_ms.IsCS(hash, span) => Field.total_cpu_time_sys_ms, + total_cpu_time_sys_us.HashCS when total_cpu_time_sys_us.IsCS(hash, span) => Field.total_cpu_time_sys_us, + total_net_bytes.HashCS when total_net_bytes.IsCS(hash, span) => Field.total_net_bytes, + by_cpu_time_us.HashCS when by_cpu_time_us.IsCS(hash, span) => Field.by_cpu_time_us, + by_net_bytes.HashCS when by_net_bytes.IsCS(hash, span) => Field.by_net_bytes, + _ => Field.Unknown, + }; + } + + [Benchmark] + public Field SequenceEqual() + { + ReadOnlySpan span = _bytes; + if (span.SequenceEqual(key.U8)) return Field.key; + if (span.SequenceEqual(abc.U8)) return Field.abc; + if (span.SequenceEqual(port.U8)) return Field.port; + if (span.SequenceEqual(test.U8)) return Field.test; + if (span.SequenceEqual(tracking_active.U8)) return Field.tracking_active; + if (span.SequenceEqual(sample_ratio.U8)) return Field.sample_ratio; + if (span.SequenceEqual(selected_slots.U8)) return Field.selected_slots; + if (span.SequenceEqual(all_commands_all_slots_us.U8)) return Field.all_commands_all_slots_us; + if (span.SequenceEqual(all_commands_selected_slots_us.U8)) return Field.all_commands_selected_slots_us; + if (span.SequenceEqual(sampled_command_selected_slots_us.U8)) return Field.sampled_command_selected_slots_us; + if (span.SequenceEqual(sampled_commands_selected_slots_us.U8)) return Field.sampled_commands_selected_slots_us; + if (span.SequenceEqual(net_bytes_all_commands_all_slots.U8)) return Field.net_bytes_all_commands_all_slots; + if (span.SequenceEqual(net_bytes_all_commands_selected_slots.U8)) return Field.net_bytes_all_commands_selected_slots; + if (span.SequenceEqual(net_bytes_sampled_commands_selected_slots.U8)) return Field.net_bytes_sampled_commands_selected_slots; + if (span.SequenceEqual(collection_start_time_unix_ms.U8)) return Field.collection_start_time_unix_ms; + if (span.SequenceEqual(collection_duration_ms.U8)) return Field.collection_duration_ms; + if (span.SequenceEqual(collection_duration_us.U8)) return Field.collection_duration_us; + if (span.SequenceEqual(total_cpu_time_user_ms.U8)) return Field.total_cpu_time_user_ms; + if (span.SequenceEqual(total_cpu_time_user_us.U8)) return Field.total_cpu_time_user_us; + if (span.SequenceEqual(total_cpu_time_sys_ms.U8)) return Field.total_cpu_time_sys_ms; + if (span.SequenceEqual(total_cpu_time_sys_us.U8)) return Field.total_cpu_time_sys_us; + if (span.SequenceEqual(total_net_bytes.U8)) return Field.total_net_bytes; + if (span.SequenceEqual(by_cpu_time_us.U8)) return Field.by_cpu_time_us; + if (span.SequenceEqual(by_net_bytes.U8)) return Field.by_net_bytes; + + return Field.Unknown; + } + + [Benchmark] + public Field SwitchOnLength() + { + ReadOnlySpan span = _bytes; + var hash = AsciiHash.HashCS(span); + return span.Length switch + { + key.Length when key.IsCS(hash, span) => Field.key, + abc.Length when abc.IsCS(hash, span) => Field.abc, + port.Length when port.IsCS(hash, span) => Field.port, + test.Length when test.IsCS(hash, span) => Field.test, + tracking_active.Length when tracking_active.IsCS(hash, span) => Field.tracking_active, + sample_ratio.Length when sample_ratio.IsCS(hash, span) => Field.sample_ratio, + selected_slots.Length when selected_slots.IsCS(hash, span) => Field.selected_slots, + all_commands_all_slots_us.Length when all_commands_all_slots_us.IsCS(hash, span) => Field.all_commands_all_slots_us, + all_commands_selected_slots_us.Length when all_commands_selected_slots_us.IsCS(hash, span) => Field.all_commands_selected_slots_us, + sampled_command_selected_slots_us.Length when sampled_command_selected_slots_us.IsCS(hash, span) => Field.sampled_command_selected_slots_us, + sampled_commands_selected_slots_us.Length when sampled_commands_selected_slots_us.IsCS(hash, span) => Field.sampled_commands_selected_slots_us, + net_bytes_all_commands_all_slots.Length when net_bytes_all_commands_all_slots.IsCS(hash, span) => Field.net_bytes_all_commands_all_slots, + net_bytes_all_commands_selected_slots.Length when net_bytes_all_commands_selected_slots.IsCS(hash, span) => Field.net_bytes_all_commands_selected_slots, + net_bytes_sampled_commands_selected_slots.Length when net_bytes_sampled_commands_selected_slots.IsCS(hash, span) => Field.net_bytes_sampled_commands_selected_slots, + collection_start_time_unix_ms.Length when collection_start_time_unix_ms.IsCS(hash, span) => Field.collection_start_time_unix_ms, + collection_duration_ms.Length when collection_duration_ms.IsCS(hash, span) => Field.collection_duration_ms, + collection_duration_us.Length when collection_duration_us.IsCS(hash, span) => Field.collection_duration_us, + total_cpu_time_user_ms.Length when total_cpu_time_user_ms.IsCS(hash, span) => Field.total_cpu_time_user_ms, + total_cpu_time_user_us.Length when total_cpu_time_user_us.IsCS(hash, span) => Field.total_cpu_time_user_us, + total_cpu_time_sys_ms.Length when total_cpu_time_sys_ms.IsCS(hash, span) => Field.total_cpu_time_sys_ms, + total_cpu_time_sys_us.Length when total_cpu_time_sys_us.IsCS(hash, span) => Field.total_cpu_time_sys_us, + total_net_bytes.Length when total_net_bytes.IsCS(hash, span) => Field.total_net_bytes, + by_cpu_time_us.Length when by_cpu_time_us.IsCS(hash, span) => Field.by_cpu_time_us, + by_net_bytes.Length when by_net_bytes.IsCS(hash, span) => Field.by_net_bytes, + _ => Field.Unknown, + }; + } + + [Benchmark] + public Field SwitchOnHash_CI() + { + ReadOnlySpan span = _bytes; + var hash = AsciiHash.HashUC(span); + return hash switch + { + key.HashCI when key.IsCI(hash, span) => Field.key, + abc.HashCI when abc.IsCI(hash, span) => Field.abc, + port.HashCI when port.IsCI(hash, span) => Field.port, + test.HashCI when test.IsCI(hash, span) => Field.test, + tracking_active.HashCI when tracking_active.IsCI(hash, span) => Field.tracking_active, + sample_ratio.HashCI when sample_ratio.IsCI(hash, span) => Field.sample_ratio, + selected_slots.HashCI when selected_slots.IsCI(hash, span) => Field.selected_slots, + all_commands_all_slots_us.HashCI when all_commands_all_slots_us.IsCI(hash, span) => Field.all_commands_all_slots_us, + all_commands_selected_slots_us.HashCI when all_commands_selected_slots_us.IsCI(hash, span) => Field.all_commands_selected_slots_us, + sampled_command_selected_slots_us.HashCI when sampled_command_selected_slots_us.IsCI(hash, span) => Field.sampled_command_selected_slots_us, + sampled_commands_selected_slots_us.HashCI when sampled_commands_selected_slots_us.IsCI(hash, span) => Field.sampled_commands_selected_slots_us, + net_bytes_all_commands_all_slots.HashCI when net_bytes_all_commands_all_slots.IsCI(hash, span) => Field.net_bytes_all_commands_all_slots, + net_bytes_all_commands_selected_slots.HashCI when net_bytes_all_commands_selected_slots.IsCI(hash, span) => Field.net_bytes_all_commands_selected_slots, + net_bytes_sampled_commands_selected_slots.HashCI when net_bytes_sampled_commands_selected_slots.IsCI(hash, span) => Field.net_bytes_sampled_commands_selected_slots, + collection_start_time_unix_ms.HashCI when collection_start_time_unix_ms.IsCI(hash, span) => Field.collection_start_time_unix_ms, + collection_duration_ms.HashCI when collection_duration_ms.IsCI(hash, span) => Field.collection_duration_ms, + collection_duration_us.HashCI when collection_duration_us.IsCI(hash, span) => Field.collection_duration_us, + total_cpu_time_user_ms.HashCI when total_cpu_time_user_ms.IsCI(hash, span) => Field.total_cpu_time_user_ms, + total_cpu_time_user_us.HashCI when total_cpu_time_user_us.IsCI(hash, span) => Field.total_cpu_time_user_us, + total_cpu_time_sys_ms.HashCI when total_cpu_time_sys_ms.IsCI(hash, span) => Field.total_cpu_time_sys_ms, + total_cpu_time_sys_us.HashCI when total_cpu_time_sys_us.IsCI(hash, span) => Field.total_cpu_time_sys_us, + total_net_bytes.HashCI when total_net_bytes.IsCI(hash, span) => Field.total_net_bytes, + by_cpu_time_us.HashCI when by_cpu_time_us.IsCI(hash, span) => Field.by_cpu_time_us, + by_net_bytes.HashCI when by_net_bytes.IsCI(hash, span) => Field.by_net_bytes, + _ => Field.Unknown, + }; + } + + [Benchmark] + public Field SwitchOnLength_CI() + { + ReadOnlySpan span = _bytes; + var hash = AsciiHash.HashUC(span); + return span.Length switch + { + key.Length when key.IsCI(hash, span) => Field.key, + abc.Length when abc.IsCI(hash, span) => Field.abc, + port.Length when port.IsCI(hash, span) => Field.port, + test.Length when test.IsCI(hash, span) => Field.test, + tracking_active.Length when tracking_active.IsCI(hash, span) => Field.tracking_active, + sample_ratio.Length when sample_ratio.IsCI(hash, span) => Field.sample_ratio, + selected_slots.Length when selected_slots.IsCI(hash, span) => Field.selected_slots, + all_commands_all_slots_us.Length when all_commands_all_slots_us.IsCI(hash, span) => Field.all_commands_all_slots_us, + all_commands_selected_slots_us.Length when all_commands_selected_slots_us.IsCI(hash, span) => Field.all_commands_selected_slots_us, + sampled_command_selected_slots_us.Length when sampled_command_selected_slots_us.IsCI(hash, span) => Field.sampled_command_selected_slots_us, + sampled_commands_selected_slots_us.Length when sampled_commands_selected_slots_us.IsCI(hash, span) => Field.sampled_commands_selected_slots_us, + net_bytes_all_commands_all_slots.Length when net_bytes_all_commands_all_slots.IsCI(hash, span) => Field.net_bytes_all_commands_all_slots, + net_bytes_all_commands_selected_slots.Length when net_bytes_all_commands_selected_slots.IsCI(hash, span) => Field.net_bytes_all_commands_selected_slots, + net_bytes_sampled_commands_selected_slots.Length when net_bytes_sampled_commands_selected_slots.IsCI(hash, span) => Field.net_bytes_sampled_commands_selected_slots, + collection_start_time_unix_ms.Length when collection_start_time_unix_ms.IsCI(hash, span) => Field.collection_start_time_unix_ms, + collection_duration_ms.Length when collection_duration_ms.IsCI(hash, span) => Field.collection_duration_ms, + collection_duration_us.Length when collection_duration_us.IsCI(hash, span) => Field.collection_duration_us, + total_cpu_time_user_ms.Length when total_cpu_time_user_ms.IsCI(hash, span) => Field.total_cpu_time_user_ms, + total_cpu_time_user_us.Length when total_cpu_time_user_us.IsCI(hash, span) => Field.total_cpu_time_user_us, + total_cpu_time_sys_ms.Length when total_cpu_time_sys_ms.IsCI(hash, span) => Field.total_cpu_time_sys_ms, + total_cpu_time_sys_us.Length when total_cpu_time_sys_us.IsCI(hash, span) => Field.total_cpu_time_sys_us, + total_net_bytes.Length when total_net_bytes.IsCI(hash, span) => Field.total_net_bytes, + by_cpu_time_us.Length when by_cpu_time_us.IsCI(hash, span) => Field.by_cpu_time_us, + by_net_bytes.Length when by_net_bytes.IsCI(hash, span) => Field.by_net_bytes, + _ => Field.Unknown, + }; + } + + /* + we're using raw output from the code-gen, because BDN kinda hates the tooling, because + of the complex build pipe; this is left for reference only + + [AsciiHash] internal static partial class key { } + [AsciiHash] internal static partial class abc { } + [AsciiHash] internal static partial class port { } + [AsciiHash] internal static partial class test { } + [AsciiHash] internal static partial class tracking_active { } + [AsciiHash] internal static partial class sample_ratio { } + [AsciiHash] internal static partial class selected_slots { } + [AsciiHash] internal static partial class all_commands_all_slots_us { } + [AsciiHash] internal static partial class all_commands_selected_slots_us { } + [AsciiHash] internal static partial class sampled_command_selected_slots_us { } + [AsciiHash] internal static partial class sampled_commands_selected_slots_us { } + [AsciiHash] internal static partial class net_bytes_all_commands_all_slots { } + [AsciiHash] internal static partial class net_bytes_all_commands_selected_slots { } + [AsciiHash] internal static partial class net_bytes_sampled_commands_selected_slots { } + [AsciiHash] internal static partial class collection_start_time_unix_ms { } + [AsciiHash] internal static partial class collection_duration_ms { } + [AsciiHash] internal static partial class collection_duration_us { } + [AsciiHash] internal static partial class total_cpu_time_user_ms { } + [AsciiHash] internal static partial class total_cpu_time_user_us { } + [AsciiHash] internal static partial class total_cpu_time_sys_ms { } + [AsciiHash] internal static partial class total_cpu_time_sys_us { } + [AsciiHash] internal static partial class total_net_bytes { } + [AsciiHash] internal static partial class by_cpu_time_us { } + [AsciiHash] internal static partial class by_net_bytes { } + */ + + static class key + { + public const int Length = 3; + public const long HashCS = 7955819; + public const long HashCI = 5850443; + public static ReadOnlySpan U8 => "key"u8; + public const string Text = "key"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS & value.Length == Length; + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && (global::RESPite.AsciiHash.HashCS(value) == HashCS || global::RESPite.AsciiHash.EqualsCI(value, U8)); + } + static class abc + { + public const int Length = 3; + public const long HashCS = 6513249; + public const long HashCI = 4407873; + public static ReadOnlySpan U8 => "abc"u8; + public const string Text = "abc"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS & value.Length == Length; + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && (global::RESPite.AsciiHash.HashCS(value) == HashCS || global::RESPite.AsciiHash.EqualsCI(value, U8)); + } + static class port + { + public const int Length = 4; + public const long HashCS = 1953656688; + public const long HashCI = 1414680400; + public static ReadOnlySpan U8 => "port"u8; + public const string Text = "port"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS & value.Length == Length; + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && (global::RESPite.AsciiHash.HashCS(value) == HashCS || global::RESPite.AsciiHash.EqualsCI(value, U8)); + } + static class test + { + public const int Length = 4; + public const long HashCS = 1953719668; + public const long HashCI = 1414743380; + public static ReadOnlySpan U8 => "test"u8; + public const string Text = "test"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS & value.Length == Length; + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && (global::RESPite.AsciiHash.HashCS(value) == HashCS || global::RESPite.AsciiHash.EqualsCI(value, U8)); + } + static class tracking_active + { + public const int Length = 15; + public const long HashCS = 7453010343294497396; + public const long HashCI = 5138124812476043860; + public static ReadOnlySpan U8 => "tracking-active"u8; + public const string Text = "tracking-active"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS && value.SequenceEqual(U8); + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && global::RESPite.AsciiHash.EqualsCI(value, U8); + } + static class sample_ratio + { + public const int Length = 12; + public const long HashCS = 8227343610692854131; + public const long HashCI = 5912458079874400595; + public static ReadOnlySpan U8 => "sample-ratio"u8; + public const string Text = "sample-ratio"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS && value.SequenceEqual(U8); + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && global::RESPite.AsciiHash.EqualsCI(value, U8); + } + static class selected_slots + { + public const int Length = 14; + public const long HashCS = 7234316346692756851; + public const long HashCI = 4919430815874303315; + public static ReadOnlySpan U8 => "selected-slots"u8; + public const string Text = "selected-slots"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS && value.SequenceEqual(U8); + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && global::RESPite.AsciiHash.EqualsCI(value, U8); + } + static class all_commands_all_slots_us + { + public const int Length = 25; + public const long HashCS = 7885080994350132321; + public const long HashCI = 5570195463531678785; + public static ReadOnlySpan U8 => "all-commands-all-slots-us"u8; + public const string Text = "all-commands-all-slots-us"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS && value.SequenceEqual(U8); + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && global::RESPite.AsciiHash.EqualsCI(value, U8); + } + static class all_commands_selected_slots_us + { + public const int Length = 30; + public const long HashCS = 7885080994350132321; + public const long HashCI = 5570195463531678785; + public static ReadOnlySpan U8 => "all-commands-selected-slots-us"u8; + public const string Text = "all-commands-selected-slots-us"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS && value.SequenceEqual(U8); + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && global::RESPite.AsciiHash.EqualsCI(value, U8); + } + static class sampled_command_selected_slots_us + { + public const int Length = 33; + public const long HashCS = 3270850745794912627; + public const long HashCI = 955965214976459091; + public static ReadOnlySpan U8 => "sampled-command-selected-slots-us"u8; + public const string Text = "sampled-command-selected-slots-us"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS && value.SequenceEqual(U8); + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && global::RESPite.AsciiHash.EqualsCI(value, U8); + } + static class sampled_commands_selected_slots_us + { + public const int Length = 34; + public const long HashCS = 3270850745794912627; + public const long HashCI = 955965214976459091; + public static ReadOnlySpan U8 => "sampled-commands-selected-slots-us"u8; + public const string Text = "sampled-commands-selected-slots-us"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS && value.SequenceEqual(U8); + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && global::RESPite.AsciiHash.EqualsCI(value, U8); + } + static class net_bytes_all_commands_all_slots + { + public const int Length = 32; + public const long HashCS = 7310601557705516398; + public const long HashCI = 4995716026887062862; + public static ReadOnlySpan U8 => "net-bytes-all-commands-all-slots"u8; + public const string Text = "net-bytes-all-commands-all-slots"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS && value.SequenceEqual(U8); + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && global::RESPite.AsciiHash.EqualsCI(value, U8); + } + static class net_bytes_all_commands_selected_slots + { + public const int Length = 37; + public const long HashCS = 7310601557705516398; + public const long HashCI = 4995716026887062862; + public static ReadOnlySpan U8 => "net-bytes-all-commands-selected-slots"u8; + public const string Text = "net-bytes-all-commands-selected-slots"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS && value.SequenceEqual(U8); + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && global::RESPite.AsciiHash.EqualsCI(value, U8); + } + static class net_bytes_sampled_commands_selected_slots + { + public const int Length = 41; + public const long HashCS = 7310601557705516398; + public const long HashCI = 4995716026887062862; + public static ReadOnlySpan U8 => "net-bytes-sampled-commands-selected-slots"u8; + public const string Text = "net-bytes-sampled-commands-selected-slots"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS && value.SequenceEqual(U8); + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && global::RESPite.AsciiHash.EqualsCI(value, U8); + } + static class collection_start_time_unix_ms + { + public const int Length = 29; + public const long HashCS = 7598807758542761827; + public const long HashCI = 5283922227724308291; + public static ReadOnlySpan U8 => "collection-start-time-unix-ms"u8; + public const string Text = "collection-start-time-unix-ms"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS && value.SequenceEqual(U8); + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && global::RESPite.AsciiHash.EqualsCI(value, U8); + } + static class collection_duration_ms + { + public const int Length = 22; + public const long HashCS = 7598807758542761827; + public const long HashCI = 5283922227724308291; + public static ReadOnlySpan U8 => "collection-duration-ms"u8; + public const string Text = "collection-duration-ms"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS && value.SequenceEqual(U8); + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && global::RESPite.AsciiHash.EqualsCI(value, U8); + } + static class collection_duration_us + { + public const int Length = 22; + public const long HashCS = 7598807758542761827; + public const long HashCI = 5283922227724308291; + public static ReadOnlySpan U8 => "collection-duration-us"u8; + public const string Text = "collection-duration-us"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS && value.SequenceEqual(U8); + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && global::RESPite.AsciiHash.EqualsCI(value, U8); + } + static class total_cpu_time_user_ms + { + public const int Length = 22; + public const long HashCS = 8098366498457022324; + public const long HashCI = 5783480967638568788; + public static ReadOnlySpan U8 => "total-cpu-time-user-ms"u8; + public const string Text = "total-cpu-time-user-ms"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS && value.SequenceEqual(U8); + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && global::RESPite.AsciiHash.EqualsCI(value, U8); + } + static class total_cpu_time_user_us + { + public const int Length = 22; + public const long HashCS = 8098366498457022324; + public const long HashCI = 5783480967638568788; + public static ReadOnlySpan U8 => "total-cpu-time-user-us"u8; + public const string Text = "total-cpu-time-user-us"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS && value.SequenceEqual(U8); + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && global::RESPite.AsciiHash.EqualsCI(value, U8); + } + static class total_cpu_time_sys_ms + { + public const int Length = 21; + public const long HashCS = 8098366498457022324; + public const long HashCI = 5783480967638568788; + public static ReadOnlySpan U8 => "total-cpu-time-sys-ms"u8; + public const string Text = "total-cpu-time-sys-ms"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS && value.SequenceEqual(U8); + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && global::RESPite.AsciiHash.EqualsCI(value, U8); + } + static class total_cpu_time_sys_us + { + public const int Length = 21; + public const long HashCS = 8098366498457022324; + public const long HashCI = 5783480967638568788; + public static ReadOnlySpan U8 => "total-cpu-time-sys-us"u8; + public const string Text = "total-cpu-time-sys-us"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS && value.SequenceEqual(U8); + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && global::RESPite.AsciiHash.EqualsCI(value, U8); + } + static class total_net_bytes + { + public const int Length = 15; + public const long HashCS = 7308829188783632244; + public const long HashCI = 4993943657965178708; + public static ReadOnlySpan U8 => "total-net-bytes"u8; + public const string Text = "total-net-bytes"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS && value.SequenceEqual(U8); + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && global::RESPite.AsciiHash.EqualsCI(value, U8); + } + static class by_cpu_time_us + { + public const int Length = 14; + public const long HashCS = 8371476407912331618; + public const long HashCI = 6056590877093878082; + public static ReadOnlySpan U8 => "by-cpu-time-us"u8; + public const string Text = "by-cpu-time-us"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS && value.SequenceEqual(U8); + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && global::RESPite.AsciiHash.EqualsCI(value, U8); + } + static class by_net_bytes + { + public const int Length = 12; + public const long HashCS = 7074438568657910114; + public const long HashCI = 4759553037839456578; + public static ReadOnlySpan U8 => "by-net-bytes"u8; + public const string Text = "by-net-bytes"; + public static bool IsCS(long hash, ReadOnlySpan value) => hash == HashCS && value.SequenceEqual(U8); + public static bool IsCI(long hash, ReadOnlySpan value) => (hash == HashCI & value.Length == Length) && global::RESPite.AsciiHash.EqualsCI(value, U8); + } +} diff --git a/tests/StackExchange.Redis.Benchmarks/CustomConfig.cs b/tests/StackExchange.Redis.Benchmarks/CustomConfig.cs index 09f44cc31..7013d4386 100644 --- a/tests/StackExchange.Redis.Benchmarks/CustomConfig.cs +++ b/tests/StackExchange.Redis.Benchmarks/CustomConfig.cs @@ -22,7 +22,7 @@ public CustomConfig() { AddJob(Configure(Job.Default.WithRuntime(ClrRuntime.Net481))); } - AddJob(Configure(Job.Default.WithRuntime(CoreRuntime.Core80))); + AddJob(Configure(Job.Default.WithRuntime(CoreRuntime.Core10_0))); } } } diff --git a/tests/StackExchange.Redis.Benchmarks/EnumParseBenchmarks.cs b/tests/StackExchange.Redis.Benchmarks/EnumParseBenchmarks.cs new file mode 100644 index 000000000..de6ae174e --- /dev/null +++ b/tests/StackExchange.Redis.Benchmarks/EnumParseBenchmarks.cs @@ -0,0 +1,690 @@ +using System; +using System.Text; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using RESPite; + +namespace StackExchange.Redis.Benchmarks; + +[ShortRunJob, MemoryDiagnoser, GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +public partial class EnumParseBenchmarks +{ + private const int OperationsPerInvoke = 1000; + + public string[] Values() => + [ + nameof(RedisCommand.GET), + nameof(RedisCommand.EXPIREAT), + nameof(RedisCommand.ZREMRANGEBYSCORE), + "~~~~", + "get", + "expireat", + "zremrangebyscore", + "GeoRadiusByMember", + ]; + + private byte[] _bytes = []; + private string _value = ""; + + [ParamsSource(nameof(Values))] + public string Value + { + get => _value; + set + { + value ??= ""; + _bytes = Encoding.UTF8.GetBytes(value); + _value = value; + } + } + + [BenchmarkCategory("Case sensitive")] + [Benchmark(OperationsPerInvoke = OperationsPerInvoke, Baseline = true)] + public RedisCommand EnumParse_CS() + { + var value = Value; + RedisCommand r = default; + for (int i = 0; i < OperationsPerInvoke; i++) + { + Enum.TryParse(value, false, out r); + } + + return r; + } + + [BenchmarkCategory("Case insensitive")] + [Benchmark(OperationsPerInvoke = OperationsPerInvoke, Baseline = true)] + public RedisCommand EnumParse_CI() + { + var value = Value; + RedisCommand r = default; + for (int i = 0; i < OperationsPerInvoke; i++) + { + Enum.TryParse(value, true, out r); + } + + return r; + } + + [BenchmarkCategory("Case sensitive")] + [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] + public RedisCommand Ascii_C_CS() + { + ReadOnlySpan value = Value; + RedisCommand r = default; + for (int i = 0; i < OperationsPerInvoke; i++) + { + TryParse_CS(value, out r); + } + + return r; + } + + [BenchmarkCategory("Case insensitive")] + [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] + public RedisCommand Ascii_C_CI() + { + ReadOnlySpan value = Value; + RedisCommand r = default; + for (int i = 0; i < OperationsPerInvoke; i++) + { + TryParse_CI(value, out r); + } + + return r; + } + + [BenchmarkCategory("Case sensitive")] + [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] + public RedisCommand Ascii_B_CS() + { + ReadOnlySpan value = _bytes; + RedisCommand r = default; + for (int i = 0; i < OperationsPerInvoke; i++) + { + TryParse_CS(value, out r); + } + + return r; + } + + [BenchmarkCategory("Case insensitive")] + [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] + public RedisCommand Ascii_B_CI() + { + ReadOnlySpan value = _bytes; + RedisCommand r = default; + for (int i = 0; i < OperationsPerInvoke; i++) + { + TryParse_CI(value, out r); + } + + return r; + } + + [BenchmarkCategory("Case sensitive")] + [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] + public RedisCommand Switch_CS() + { + var value = Value; + RedisCommand r = default; + for (int i = 0; i < OperationsPerInvoke; i++) + { + TryParseSwitch(value, out r); + } + + return r; + } + + private static bool TryParseSwitch(string s, out RedisCommand r) + { + r = s switch + { + "NONE" => RedisCommand.NONE, + "APPEND" => RedisCommand.APPEND, + "ASKING" => RedisCommand.ASKING, + "AUTH" => RedisCommand.AUTH, + "BGREWRITEAOF" => RedisCommand.BGREWRITEAOF, + "BGSAVE" => RedisCommand.BGSAVE, + "BITCOUNT" => RedisCommand.BITCOUNT, + "BITOP" => RedisCommand.BITOP, + "BITPOS" => RedisCommand.BITPOS, + "BLPOP" => RedisCommand.BLPOP, + "BRPOP" => RedisCommand.BRPOP, + "BRPOPLPUSH" => RedisCommand.BRPOPLPUSH, + "CLIENT" => RedisCommand.CLIENT, + "CLUSTER" => RedisCommand.CLUSTER, + "CONFIG" => RedisCommand.CONFIG, + "COPY" => RedisCommand.COPY, + "COMMAND" => RedisCommand.COMMAND, + "DBSIZE" => RedisCommand.DBSIZE, + "DEBUG" => RedisCommand.DEBUG, + "DECR" => RedisCommand.DECR, + "DECRBY" => RedisCommand.DECRBY, + "DEL" => RedisCommand.DEL, + "DELEX" => RedisCommand.DELEX, + "DIGEST" => RedisCommand.DIGEST, + "DISCARD" => RedisCommand.DISCARD, + "DUMP" => RedisCommand.DUMP, + "ECHO" => RedisCommand.ECHO, + "EVAL" => RedisCommand.EVAL, + "EVALSHA" => RedisCommand.EVALSHA, + "EVAL_RO" => RedisCommand.EVAL_RO, + "EVALSHA_RO" => RedisCommand.EVALSHA_RO, + "EXEC" => RedisCommand.EXEC, + "EXISTS" => RedisCommand.EXISTS, + "EXPIRE" => RedisCommand.EXPIRE, + "EXPIREAT" => RedisCommand.EXPIREAT, + "EXPIRETIME" => RedisCommand.EXPIRETIME, + "FLUSHALL" => RedisCommand.FLUSHALL, + "FLUSHDB" => RedisCommand.FLUSHDB, + "GEOADD" => RedisCommand.GEOADD, + "GEODIST" => RedisCommand.GEODIST, + "GEOHASH" => RedisCommand.GEOHASH, + "GEOPOS" => RedisCommand.GEOPOS, + "GEORADIUS" => RedisCommand.GEORADIUS, + "GEORADIUSBYMEMBER" => RedisCommand.GEORADIUSBYMEMBER, + "GEOSEARCH" => RedisCommand.GEOSEARCH, + "GEOSEARCHSTORE" => RedisCommand.GEOSEARCHSTORE, + "GET" => RedisCommand.GET, + "GETBIT" => RedisCommand.GETBIT, + "GETDEL" => RedisCommand.GETDEL, + "GETEX" => RedisCommand.GETEX, + "GETRANGE" => RedisCommand.GETRANGE, + "GETSET" => RedisCommand.GETSET, + "HDEL" => RedisCommand.HDEL, + "HELLO" => RedisCommand.HELLO, + "HEXISTS" => RedisCommand.HEXISTS, + "HEXPIRE" => RedisCommand.HEXPIRE, + "HEXPIREAT" => RedisCommand.HEXPIREAT, + "HEXPIRETIME" => RedisCommand.HEXPIRETIME, + "HGET" => RedisCommand.HGET, + "HGETEX" => RedisCommand.HGETEX, + "HGETDEL" => RedisCommand.HGETDEL, + "HGETALL" => RedisCommand.HGETALL, + "HINCRBY" => RedisCommand.HINCRBY, + "HINCRBYFLOAT" => RedisCommand.HINCRBYFLOAT, + "HKEYS" => RedisCommand.HKEYS, + "HLEN" => RedisCommand.HLEN, + "HMGET" => RedisCommand.HMGET, + "HMSET" => RedisCommand.HMSET, + "HOTKEYS" => RedisCommand.HOTKEYS, + "HPERSIST" => RedisCommand.HPERSIST, + "HPEXPIRE" => RedisCommand.HPEXPIRE, + "HPEXPIREAT" => RedisCommand.HPEXPIREAT, + "HPEXPIRETIME" => RedisCommand.HPEXPIRETIME, + "HPTTL" => RedisCommand.HPTTL, + "HRANDFIELD" => RedisCommand.HRANDFIELD, + "HSCAN" => RedisCommand.HSCAN, + "HSET" => RedisCommand.HSET, + "HSETEX" => RedisCommand.HSETEX, + "HSETNX" => RedisCommand.HSETNX, + "HSTRLEN" => RedisCommand.HSTRLEN, + "HVALS" => RedisCommand.HVALS, + "INCR" => RedisCommand.INCR, + "INCRBY" => RedisCommand.INCRBY, + "INCRBYFLOAT" => RedisCommand.INCRBYFLOAT, + "INFO" => RedisCommand.INFO, + "KEYS" => RedisCommand.KEYS, + "LASTSAVE" => RedisCommand.LASTSAVE, + "LATENCY" => RedisCommand.LATENCY, + "LCS" => RedisCommand.LCS, + "LINDEX" => RedisCommand.LINDEX, + "LINSERT" => RedisCommand.LINSERT, + "LLEN" => RedisCommand.LLEN, + "LMOVE" => RedisCommand.LMOVE, + "LMPOP" => RedisCommand.LMPOP, + "LPOP" => RedisCommand.LPOP, + "LPOS" => RedisCommand.LPOS, + "LPUSH" => RedisCommand.LPUSH, + "LPUSHX" => RedisCommand.LPUSHX, + "LRANGE" => RedisCommand.LRANGE, + "LREM" => RedisCommand.LREM, + "LSET" => RedisCommand.LSET, + "LTRIM" => RedisCommand.LTRIM, + "MEMORY" => RedisCommand.MEMORY, + "MGET" => RedisCommand.MGET, + "MIGRATE" => RedisCommand.MIGRATE, + "MONITOR" => RedisCommand.MONITOR, + "MOVE" => RedisCommand.MOVE, + "MSET" => RedisCommand.MSET, + "MSETEX" => RedisCommand.MSETEX, + "MSETNX" => RedisCommand.MSETNX, + "MULTI" => RedisCommand.MULTI, + "OBJECT" => RedisCommand.OBJECT, + "PERSIST" => RedisCommand.PERSIST, + "PEXPIRE" => RedisCommand.PEXPIRE, + "PEXPIREAT" => RedisCommand.PEXPIREAT, + "PEXPIRETIME" => RedisCommand.PEXPIRETIME, + "PFADD" => RedisCommand.PFADD, + "PFCOUNT" => RedisCommand.PFCOUNT, + "PFMERGE" => RedisCommand.PFMERGE, + "PING" => RedisCommand.PING, + "PSETEX" => RedisCommand.PSETEX, + "PSUBSCRIBE" => RedisCommand.PSUBSCRIBE, + "PTTL" => RedisCommand.PTTL, + "PUBLISH" => RedisCommand.PUBLISH, + "PUBSUB" => RedisCommand.PUBSUB, + "PUNSUBSCRIBE" => RedisCommand.PUNSUBSCRIBE, + "QUIT" => RedisCommand.QUIT, + "RANDOMKEY" => RedisCommand.RANDOMKEY, + "READONLY" => RedisCommand.READONLY, + "READWRITE" => RedisCommand.READWRITE, + "RENAME" => RedisCommand.RENAME, + "RENAMENX" => RedisCommand.RENAMENX, + "REPLICAOF" => RedisCommand.REPLICAOF, + "RESTORE" => RedisCommand.RESTORE, + "ROLE" => RedisCommand.ROLE, + "RPOP" => RedisCommand.RPOP, + "RPOPLPUSH" => RedisCommand.RPOPLPUSH, + "RPUSH" => RedisCommand.RPUSH, + "RPUSHX" => RedisCommand.RPUSHX, + "SADD" => RedisCommand.SADD, + "SAVE" => RedisCommand.SAVE, + "SCAN" => RedisCommand.SCAN, + "SCARD" => RedisCommand.SCARD, + "SCRIPT" => RedisCommand.SCRIPT, + "SDIFF" => RedisCommand.SDIFF, + "SDIFFSTORE" => RedisCommand.SDIFFSTORE, + "SELECT" => RedisCommand.SELECT, + "SENTINEL" => RedisCommand.SENTINEL, + "SET" => RedisCommand.SET, + "SETBIT" => RedisCommand.SETBIT, + "SETEX" => RedisCommand.SETEX, + "SETNX" => RedisCommand.SETNX, + "SETRANGE" => RedisCommand.SETRANGE, + "SHUTDOWN" => RedisCommand.SHUTDOWN, + "SINTER" => RedisCommand.SINTER, + "SINTERCARD" => RedisCommand.SINTERCARD, + "SINTERSTORE" => RedisCommand.SINTERSTORE, + "SISMEMBER" => RedisCommand.SISMEMBER, + "SLAVEOF" => RedisCommand.SLAVEOF, + "SLOWLOG" => RedisCommand.SLOWLOG, + "SMEMBERS" => RedisCommand.SMEMBERS, + "SMISMEMBER" => RedisCommand.SMISMEMBER, + "SMOVE" => RedisCommand.SMOVE, + "SORT" => RedisCommand.SORT, + "SORT_RO" => RedisCommand.SORT_RO, + "SPOP" => RedisCommand.SPOP, + "SPUBLISH" => RedisCommand.SPUBLISH, + "SRANDMEMBER" => RedisCommand.SRANDMEMBER, + "SREM" => RedisCommand.SREM, + "STRLEN" => RedisCommand.STRLEN, + "SUBSCRIBE" => RedisCommand.SUBSCRIBE, + "SUNION" => RedisCommand.SUNION, + "SUNIONSTORE" => RedisCommand.SUNIONSTORE, + "SSCAN" => RedisCommand.SSCAN, + "SSUBSCRIBE" => RedisCommand.SSUBSCRIBE, + "SUNSUBSCRIBE" => RedisCommand.SUNSUBSCRIBE, + "SWAPDB" => RedisCommand.SWAPDB, + "SYNC" => RedisCommand.SYNC, + "TIME" => RedisCommand.TIME, + "TOUCH" => RedisCommand.TOUCH, + "TTL" => RedisCommand.TTL, + "TYPE" => RedisCommand.TYPE, + "UNLINK" => RedisCommand.UNLINK, + "UNSUBSCRIBE" => RedisCommand.UNSUBSCRIBE, + "UNWATCH" => RedisCommand.UNWATCH, + "VADD" => RedisCommand.VADD, + "VCARD" => RedisCommand.VCARD, + "VDIM" => RedisCommand.VDIM, + "VEMB" => RedisCommand.VEMB, + "VGETATTR" => RedisCommand.VGETATTR, + "VINFO" => RedisCommand.VINFO, + "VISMEMBER" => RedisCommand.VISMEMBER, + "VLINKS" => RedisCommand.VLINKS, + "VRANDMEMBER" => RedisCommand.VRANDMEMBER, + "VREM" => RedisCommand.VREM, + "VSETATTR" => RedisCommand.VSETATTR, + "VSIM" => RedisCommand.VSIM, + "WATCH" => RedisCommand.WATCH, + "XACK" => RedisCommand.XACK, + "XACKDEL" => RedisCommand.XACKDEL, + "XADD" => RedisCommand.XADD, + "XAUTOCLAIM" => RedisCommand.XAUTOCLAIM, + "XCLAIM" => RedisCommand.XCLAIM, + "XCFGSET" => RedisCommand.XCFGSET, + "XDEL" => RedisCommand.XDEL, + "XDELEX" => RedisCommand.XDELEX, + "XGROUP" => RedisCommand.XGROUP, + "XINFO" => RedisCommand.XINFO, + "XLEN" => RedisCommand.XLEN, + "XPENDING" => RedisCommand.XPENDING, + "XRANGE" => RedisCommand.XRANGE, + "XREAD" => RedisCommand.XREAD, + "XREADGROUP" => RedisCommand.XREADGROUP, + "XREVRANGE" => RedisCommand.XREVRANGE, + "XTRIM" => RedisCommand.XTRIM, + "ZADD" => RedisCommand.ZADD, + "ZCARD" => RedisCommand.ZCARD, + "ZCOUNT" => RedisCommand.ZCOUNT, + "ZDIFF" => RedisCommand.ZDIFF, + "ZDIFFSTORE" => RedisCommand.ZDIFFSTORE, + "ZINCRBY" => RedisCommand.ZINCRBY, + "ZINTER" => RedisCommand.ZINTER, + "ZINTERCARD" => RedisCommand.ZINTERCARD, + "ZINTERSTORE" => RedisCommand.ZINTERSTORE, + "ZLEXCOUNT" => RedisCommand.ZLEXCOUNT, + "ZMPOP" => RedisCommand.ZMPOP, + "ZMSCORE" => RedisCommand.ZMSCORE, + "ZPOPMAX" => RedisCommand.ZPOPMAX, + "ZPOPMIN" => RedisCommand.ZPOPMIN, + "ZRANDMEMBER" => RedisCommand.ZRANDMEMBER, + "ZRANGE" => RedisCommand.ZRANGE, + "ZRANGEBYLEX" => RedisCommand.ZRANGEBYLEX, + "ZRANGEBYSCORE" => RedisCommand.ZRANGEBYSCORE, + "ZRANGESTORE" => RedisCommand.ZRANGESTORE, + "ZRANK" => RedisCommand.ZRANK, + "ZREM" => RedisCommand.ZREM, + "ZREMRANGEBYLEX" => RedisCommand.ZREMRANGEBYLEX, + "ZREMRANGEBYRANK" => RedisCommand.ZREMRANGEBYRANK, + "ZREMRANGEBYSCORE" => RedisCommand.ZREMRANGEBYSCORE, + "ZREVRANGE" => RedisCommand.ZREVRANGE, + "ZREVRANGEBYLEX" => RedisCommand.ZREVRANGEBYLEX, + "ZREVRANGEBYSCORE" => RedisCommand.ZREVRANGEBYSCORE, + "ZREVRANK" => RedisCommand.ZREVRANK, + "ZSCAN" => RedisCommand.ZSCAN, + "ZSCORE" => RedisCommand.ZSCORE, + "ZUNION" => RedisCommand.ZUNION, + "ZUNIONSTORE" => RedisCommand.ZUNIONSTORE, + "UNKNOWN" => RedisCommand.UNKNOWN, + _ => (RedisCommand)(-1), + }; + if (r == (RedisCommand)(-1)) + { + r = default; + return false; + } + + return true; + } + + [AsciiHash] + internal static partial bool TryParse_CS(ReadOnlySpan value, out RedisCommand command); + + [AsciiHash] + internal static partial bool TryParse_CS(ReadOnlySpan value, out RedisCommand command); + + [AsciiHash(CaseSensitive = false)] + internal static partial bool TryParse_CI(ReadOnlySpan value, out RedisCommand command); + + [AsciiHash(CaseSensitive = false)] + internal static partial bool TryParse_CI(ReadOnlySpan value, out RedisCommand command); + + public enum RedisCommand + { + NONE, // must be first for "zero reasons" + + APPEND, + ASKING, + AUTH, + + BGREWRITEAOF, + BGSAVE, + BITCOUNT, + BITOP, + BITPOS, + BLPOP, + BRPOP, + BRPOPLPUSH, + + CLIENT, + CLUSTER, + CONFIG, + COPY, + COMMAND, + + DBSIZE, + DEBUG, + DECR, + DECRBY, + DEL, + DELEX, + DIGEST, + DISCARD, + DUMP, + + ECHO, + EVAL, + EVALSHA, + EVAL_RO, + EVALSHA_RO, + EXEC, + EXISTS, + EXPIRE, + EXPIREAT, + EXPIRETIME, + + FLUSHALL, + FLUSHDB, + + GEOADD, + GEODIST, + GEOHASH, + GEOPOS, + GEORADIUS, + GEORADIUSBYMEMBER, + GEOSEARCH, + GEOSEARCHSTORE, + + GET, + GETBIT, + GETDEL, + GETEX, + GETRANGE, + GETSET, + + HDEL, + HELLO, + HEXISTS, + HEXPIRE, + HEXPIREAT, + HEXPIRETIME, + HGET, + HGETEX, + HGETDEL, + HGETALL, + HINCRBY, + HINCRBYFLOAT, + HKEYS, + HLEN, + HMGET, + HMSET, + HOTKEYS, + HPERSIST, + HPEXPIRE, + HPEXPIREAT, + HPEXPIRETIME, + HPTTL, + HRANDFIELD, + HSCAN, + HSET, + HSETEX, + HSETNX, + HSTRLEN, + HVALS, + + INCR, + INCRBY, + INCRBYFLOAT, + INFO, + + KEYS, + + LASTSAVE, + LATENCY, + LCS, + LINDEX, + LINSERT, + LLEN, + LMOVE, + LMPOP, + LPOP, + LPOS, + LPUSH, + LPUSHX, + LRANGE, + LREM, + LSET, + LTRIM, + + MEMORY, + MGET, + MIGRATE, + MONITOR, + MOVE, + MSET, + MSETEX, + MSETNX, + MULTI, + + OBJECT, + + PERSIST, + PEXPIRE, + PEXPIREAT, + PEXPIRETIME, + PFADD, + PFCOUNT, + PFMERGE, + PING, + PSETEX, + PSUBSCRIBE, + PTTL, + PUBLISH, + PUBSUB, + PUNSUBSCRIBE, + + QUIT, + + RANDOMKEY, + READONLY, + READWRITE, + RENAME, + RENAMENX, + REPLICAOF, + RESTORE, + ROLE, + RPOP, + RPOPLPUSH, + RPUSH, + RPUSHX, + + SADD, + SAVE, + SCAN, + SCARD, + SCRIPT, + SDIFF, + SDIFFSTORE, + SELECT, + SENTINEL, + SET, + SETBIT, + SETEX, + SETNX, + SETRANGE, + SHUTDOWN, + SINTER, + SINTERCARD, + SINTERSTORE, + SISMEMBER, + SLAVEOF, + SLOWLOG, + SMEMBERS, + SMISMEMBER, + SMOVE, + SORT, + SORT_RO, + SPOP, + SPUBLISH, + SRANDMEMBER, + SREM, + STRLEN, + SUBSCRIBE, + SUNION, + SUNIONSTORE, + SSCAN, + SSUBSCRIBE, + SUNSUBSCRIBE, + SWAPDB, + SYNC, + + TIME, + TOUCH, + TTL, + TYPE, + + UNLINK, + UNSUBSCRIBE, + UNWATCH, + + VADD, + VCARD, + VDIM, + VEMB, + VGETATTR, + VINFO, + VISMEMBER, + VLINKS, + VRANDMEMBER, + VREM, + VSETATTR, + VSIM, + + WATCH, + + XACK, + XACKDEL, + XADD, + XAUTOCLAIM, + XCLAIM, + XCFGSET, + XDEL, + XDELEX, + XGROUP, + XINFO, + XLEN, + XPENDING, + XRANGE, + XREAD, + XREADGROUP, + XREVRANGE, + XTRIM, + + ZADD, + ZCARD, + ZCOUNT, + ZDIFF, + ZDIFFSTORE, + ZINCRBY, + ZINTER, + ZINTERCARD, + ZINTERSTORE, + ZLEXCOUNT, + ZMPOP, + ZMSCORE, + ZPOPMAX, + ZPOPMIN, + ZRANDMEMBER, + ZRANGE, + ZRANGEBYLEX, + ZRANGEBYSCORE, + ZRANGESTORE, + ZRANK, + ZREM, + ZREMRANGEBYLEX, + ZREMRANGEBYRANK, + ZREMRANGEBYSCORE, + ZREVRANGE, + ZREVRANGEBYLEX, + ZREVRANGEBYSCORE, + ZREVRANK, + ZSCAN, + ZSCORE, + ZUNION, + ZUNIONSTORE, + + UNKNOWN, + } +} diff --git a/tests/StackExchange.Redis.Benchmarks/FormatBenchmarks.cs b/tests/StackExchange.Redis.Benchmarks/FormatBenchmarks.cs index 77548b254..714e1724a 100644 --- a/tests/StackExchange.Redis.Benchmarks/FormatBenchmarks.cs +++ b/tests/StackExchange.Redis.Benchmarks/FormatBenchmarks.cs @@ -1,4 +1,5 @@ -using System; +/* +using System; using System.Net; using BenchmarkDotNet.Attributes; @@ -51,3 +52,4 @@ public void Setup() { } public EndPoint ParseEndPoint(string host, int port) => Format.ParseEndPoint(host, port); } } +*/ diff --git a/tests/StackExchange.Redis.Benchmarks/StackExchange.Redis.Benchmarks.csproj b/tests/StackExchange.Redis.Benchmarks/StackExchange.Redis.Benchmarks.csproj index 8b335ab02..6b921a92f 100644 --- a/tests/StackExchange.Redis.Benchmarks/StackExchange.Redis.Benchmarks.csproj +++ b/tests/StackExchange.Redis.Benchmarks/StackExchange.Redis.Benchmarks.csproj @@ -10,7 +10,9 @@ - - + + + + diff --git a/tests/StackExchange.Redis.Tests/App.config b/tests/StackExchange.Redis.Tests/App.config index c7c0b6d7a..295bdd49d 100644 --- a/tests/StackExchange.Redis.Tests/App.config +++ b/tests/StackExchange.Redis.Tests/App.config @@ -4,7 +4,7 @@ - + diff --git a/tests/StackExchange.Redis.Tests/AsciiHashUnitTests.cs b/tests/StackExchange.Redis.Tests/AsciiHashUnitTests.cs new file mode 100644 index 000000000..5e2b9571f --- /dev/null +++ b/tests/StackExchange.Redis.Tests/AsciiHashUnitTests.cs @@ -0,0 +1,460 @@ +using System; +using System.Runtime.InteropServices; +using System.Text; +using RESPite; +using Xunit; +using Xunit.Sdk; + +#pragma warning disable CS8981, SA1134, SA1300, SA1303, SA1502 // names are weird in this test! +// ReSharper disable InconsistentNaming - to better represent expected literals +// ReSharper disable IdentifierTypo +namespace StackExchange.Redis.Tests; + +public partial class AsciiHashUnitTests +{ + // note: if the hashing algorithm changes, we can update the last parameter freely; it doesn't matter + // what it *is* - what matters is that we can see that it has entropy between different values + [Theory] + [InlineData(1, a.Length, a.Text, a.HashCS, 97)] + [InlineData(2, ab.Length, ab.Text, ab.HashCS, 25185)] + [InlineData(3, abc.Length, abc.Text, abc.HashCS, 6513249)] + [InlineData(4, abcd.Length, abcd.Text, abcd.HashCS, 1684234849)] + [InlineData(5, abcde.Length, abcde.Text, abcde.HashCS, 435475931745)] + [InlineData(6, abcdef.Length, abcdef.Text, abcdef.HashCS, 112585661964897)] + [InlineData(7, abcdefg.Length, abcdefg.Text, abcdefg.HashCS, 29104508263162465)] + [InlineData(8, abcdefgh.Length, abcdefgh.Text, abcdefgh.HashCS, 7523094288207667809)] + + [InlineData(1, x.Length, x.Text, x.HashCS, 120)] + [InlineData(2, xx.Length, xx.Text, xx.HashCS, 30840)] + [InlineData(3, xxx.Length, xxx.Text, xxx.HashCS, 7895160)] + [InlineData(4, xxxx.Length, xxxx.Text, xxxx.HashCS, 2021161080)] + [InlineData(5, xxxxx.Length, xxxxx.Text, xxxxx.HashCS, 517417236600)] + [InlineData(6, xxxxxx.Length, xxxxxx.Text, xxxxxx.HashCS, 132458812569720)] + [InlineData(7, xxxxxxx.Length, xxxxxxx.Text, xxxxxxx.HashCS, 33909456017848440)] + [InlineData(8, xxxxxxxx.Length, xxxxxxxx.Text, xxxxxxxx.HashCS, 8680820740569200760)] + + [InlineData(20, abcdefghijklmnopqrst.Length, abcdefghijklmnopqrst.Text, abcdefghijklmnopqrst.HashCS, 7523094288207667809)] + + // show that foo_bar is interpreted as foo-bar + [InlineData(7, foo_bar.Length, foo_bar.Text, foo_bar.HashCS, 32195221641981798, "foo-bar", nameof(foo_bar))] + [InlineData(7, foo_bar_hyphen.Length, foo_bar_hyphen.Text, foo_bar_hyphen.HashCS, 32195221641981798, "foo-bar", nameof(foo_bar_hyphen))] + [InlineData(7, foo_bar_underscore.Length, foo_bar_underscore.Text, foo_bar_underscore.HashCS, 32195222480842598, "foo_bar", nameof(foo_bar_underscore))] + public void Validate(int expectedLength, int actualLength, string actualValue, long actualHash, long expectedHash, string? expectedValue = null, string originForDisambiguation = "") + { + _ = originForDisambiguation; // to allow otherwise-identical test data to coexist + Assert.Equal(expectedLength, actualLength); + Assert.Equal(expectedHash, actualHash); + var bytes = Encoding.UTF8.GetBytes(actualValue); + Assert.Equal(expectedLength, bytes.Length); + Assert.Equal(expectedHash, AsciiHash.HashCS(bytes)); + Assert.Equal(expectedHash, AsciiHash.HashCS(actualValue.AsSpan())); + + if (expectedValue is not null) + { + Assert.Equal(expectedValue, actualValue); + } + } + + [Fact] + public void AsciiHashIs_Short() + { + ReadOnlySpan value = "abc"u8; + var hash = AsciiHash.HashCS(value); + Assert.Equal(abc.HashCS, hash); + Assert.True(abc.IsCS(value, hash)); + + value = "abz"u8; + hash = AsciiHash.HashCS(value); + Assert.NotEqual(abc.HashCS, hash); + Assert.False(abc.IsCS(value, hash)); + } + + [Fact] + public void AsciiHashIs_Long() + { + ReadOnlySpan value = "abcdefghijklmnopqrst"u8; + var hash = AsciiHash.HashCS(value); + Assert.Equal(abcdefghijklmnopqrst.HashCS, hash); + Assert.True(abcdefghijklmnopqrst.IsCS(value, hash)); + + value = "abcdefghijklmnopqrsz"u8; + hash = AsciiHash.HashCS(value); + Assert.Equal(abcdefghijklmnopqrst.HashCS, hash); // hash collision, fine + Assert.False(abcdefghijklmnopqrst.IsCS(value, hash)); + } + + // Test case-sensitive and case-insensitive equality for various lengths + [Theory] + [InlineData("a")] // length 1 + [InlineData("ab")] // length 2 + [InlineData("abc")] // length 3 + [InlineData("abcd")] // length 4 + [InlineData("abcde")] // length 5 + [InlineData("abcdef")] // length 6 + [InlineData("abcdefg")] // length 7 + [InlineData("abcdefgh")] // length 8 + [InlineData("abcdefghi")] // length 9 + [InlineData("abcdefghij")] // length 10 + [InlineData("abcdefghijklmnop")] // length 16 + [InlineData("abcdefghijklmnopqrst")] // length 20 + public void CaseSensitiveEquality(string text) + { + var lower = Encoding.UTF8.GetBytes(text); + var upper = Encoding.UTF8.GetBytes(text.ToUpperInvariant()); + + var hashLowerCS = AsciiHash.HashCS(lower); + var hashUpperCS = AsciiHash.HashCS(upper); + + // Case-sensitive: same case should match + Assert.True(AsciiHash.EqualsCS(lower, lower), "CS: lower == lower"); + Assert.True(AsciiHash.EqualsCS(upper, upper), "CS: upper == upper"); + + // Case-sensitive: different case should NOT match + Assert.False(AsciiHash.EqualsCS(lower, upper), "CS: lower != upper"); + Assert.False(AsciiHash.EqualsCS(upper, lower), "CS: upper != lower"); + + // Hashes should be different for different cases + Assert.NotEqual(hashLowerCS, hashUpperCS); + } + + [Theory] + [InlineData("a")] // length 1 + [InlineData("ab")] // length 2 + [InlineData("abc")] // length 3 + [InlineData("abcd")] // length 4 + [InlineData("abcde")] // length 5 + [InlineData("abcdef")] // length 6 + [InlineData("abcdefg")] // length 7 + [InlineData("abcdefgh")] // length 8 + [InlineData("abcdefghi")] // length 9 + [InlineData("abcdefghij")] // length 10 + [InlineData("abcdefghijklmnop")] // length 16 + [InlineData("abcdefghijklmnopqrst")] // length 20 + public void CaseInsensitiveEquality(string text) + { + var lower = Encoding.UTF8.GetBytes(text); + var upper = Encoding.UTF8.GetBytes(text.ToUpperInvariant()); + + var hashLowerUC = AsciiHash.HashUC(lower); + var hashUpperUC = AsciiHash.HashUC(upper); + + // Case-insensitive: same case should match + Assert.True(AsciiHash.EqualsCI(lower, lower), "CI: lower == lower"); + Assert.True(AsciiHash.EqualsCI(upper, upper), "CI: upper == upper"); + + // Case-insensitive: different case SHOULD match + Assert.True(AsciiHash.EqualsCI(lower, upper), "CI: lower == upper"); + Assert.True(AsciiHash.EqualsCI(upper, lower), "CI: upper == lower"); + + // CI hashes should be the same for different cases + Assert.Equal(hashLowerUC, hashUpperUC); + } + + [Theory] + [InlineData("a")] // length 1 + [InlineData("ab")] // length 2 + [InlineData("abc")] // length 3 + [InlineData("abcd")] // length 4 + [InlineData("abcde")] // length 5 + [InlineData("abcdef")] // length 6 + [InlineData("abcdefg")] // length 7 + [InlineData("abcdefgh")] // length 8 + [InlineData("abcdefghi")] // length 9 + [InlineData("abcdefghij")] // length 10 + [InlineData("abcdefghijklmnop")] // length 16 + [InlineData("abcdefghijklmnopqrst")] // length 20 + [InlineData("foo-bar")] // foo_bar_hyphen + [InlineData("foo_bar")] // foo_bar_underscore + public void GeneratedTypes_CaseSensitive(string text) + { + var lower = Encoding.UTF8.GetBytes(text); + var upper = Encoding.UTF8.GetBytes(text.ToUpperInvariant()); + + var hashLowerCS = AsciiHash.HashCS(lower); + var hashUpperCS = AsciiHash.HashCS(upper); + + // Use the generated types to verify CS behavior + switch (text) + { + case "a": + Assert.True(a.IsCS(lower, hashLowerCS)); + Assert.False(a.IsCS(lower, hashUpperCS)); + break; + case "ab": + Assert.True(ab.IsCS(lower, hashLowerCS)); + Assert.False(ab.IsCS(lower, hashUpperCS)); + break; + case "abc": + Assert.True(abc.IsCS(lower, hashLowerCS)); + Assert.False(abc.IsCS(lower, hashUpperCS)); + break; + case "abcd": + Assert.True(abcd.IsCS(lower, hashLowerCS)); + Assert.False(abcd.IsCS(lower, hashUpperCS)); + break; + case "abcde": + Assert.True(abcde.IsCS(lower, hashLowerCS)); + Assert.False(abcde.IsCS(lower, hashUpperCS)); + break; + case "abcdef": + Assert.True(abcdef.IsCS(lower, hashLowerCS)); + Assert.False(abcdef.IsCS(lower, hashUpperCS)); + break; + case "abcdefg": + Assert.True(abcdefg.IsCS(lower, hashLowerCS)); + Assert.False(abcdefg.IsCS(lower, hashUpperCS)); + break; + case "abcdefgh": + Assert.True(abcdefgh.IsCS(lower, hashLowerCS)); + Assert.False(abcdefgh.IsCS(lower, hashUpperCS)); + break; + case "abcdefghijklmnopqrst": + Assert.True(abcdefghijklmnopqrst.IsCS(lower, hashLowerCS)); + Assert.False(abcdefghijklmnopqrst.IsCS(lower, hashUpperCS)); + break; + case "foo-bar": + Assert.True(foo_bar_hyphen.IsCS(lower, hashLowerCS)); + Assert.False(foo_bar_hyphen.IsCS(lower, hashUpperCS)); + break; + case "foo_bar": + Assert.True(foo_bar_underscore.IsCS(lower, hashLowerCS)); + Assert.False(foo_bar_underscore.IsCS(lower, hashUpperCS)); + break; + } + } + + [Theory] + [InlineData("a")] // length 1 + [InlineData("ab")] // length 2 + [InlineData("abc")] // length 3 + [InlineData("abcd")] // length 4 + [InlineData("abcde")] // length 5 + [InlineData("abcdef")] // length 6 + [InlineData("abcdefg")] // length 7 + [InlineData("abcdefgh")] // length 8 + [InlineData("abcdefghi")] // length 9 + [InlineData("abcdefghij")] // length 10 + [InlineData("abcdefghijklmnop")] // length 16 + [InlineData("abcdefghijklmnopqrst")] // length 20 + [InlineData("foo-bar")] // foo_bar_hyphen + [InlineData("foo_bar")] // foo_bar_underscore + public void GeneratedTypes_CaseInsensitive(string text) + { + var lower = Encoding.UTF8.GetBytes(text); + var upper = Encoding.UTF8.GetBytes(text.ToUpperInvariant()); + + var hashLowerUC = AsciiHash.HashUC(lower); + var hashUpperUC = AsciiHash.HashUC(upper); + + // Use the generated types to verify CI behavior + switch (text) + { + case "a": + Assert.True(a.IsCI(lower, hashLowerUC)); + Assert.True(a.IsCI(upper, hashUpperUC)); + break; + case "ab": + Assert.True(ab.IsCI(lower, hashLowerUC)); + Assert.True(ab.IsCI(upper, hashUpperUC)); + break; + case "abc": + Assert.True(abc.IsCI(lower, hashLowerUC)); + Assert.True(abc.IsCI(upper, hashUpperUC)); + break; + case "abcd": + Assert.True(abcd.IsCI(lower, hashLowerUC)); + Assert.True(abcd.IsCI(upper, hashUpperUC)); + break; + case "abcde": + Assert.True(abcde.IsCI(lower, hashLowerUC)); + Assert.True(abcde.IsCI(upper, hashUpperUC)); + break; + case "abcdef": + Assert.True(abcdef.IsCI(lower, hashLowerUC)); + Assert.True(abcdef.IsCI(upper, hashUpperUC)); + break; + case "abcdefg": + Assert.True(abcdefg.IsCI(lower, hashLowerUC)); + Assert.True(abcdefg.IsCI(upper, hashUpperUC)); + break; + case "abcdefgh": + Assert.True(abcdefgh.IsCI(lower, hashLowerUC)); + Assert.True(abcdefgh.IsCI(upper, hashUpperUC)); + break; + case "abcdefghijklmnopqrst": + Assert.True(abcdefghijklmnopqrst.IsCI(lower, hashLowerUC)); + Assert.True(abcdefghijklmnopqrst.IsCI(upper, hashUpperUC)); + break; + case "foo-bar": + Assert.True(foo_bar_hyphen.IsCI(lower, hashLowerUC)); + Assert.True(foo_bar_hyphen.IsCI(upper, hashUpperUC)); + break; + case "foo_bar": + Assert.True(foo_bar_underscore.IsCI(lower, hashLowerUC)); + Assert.True(foo_bar_underscore.IsCI(upper, hashUpperUC)); + break; + } + } + + // Test each generated AsciiHash type individually for case sensitivity + [Fact] + public void GeneratedType_a_CaseSensitivity() + { + ReadOnlySpan lower = "a"u8; + ReadOnlySpan upper = "A"u8; + + Assert.True(a.IsCS(lower, AsciiHash.HashCS(lower))); + Assert.False(a.IsCS(upper, AsciiHash.HashCS(upper))); + Assert.True(a.IsCI(lower, AsciiHash.HashUC(lower))); + Assert.True(a.IsCI(upper, AsciiHash.HashUC(upper))); + } + + [Fact] + public void GeneratedType_ab_CaseSensitivity() + { + ReadOnlySpan lower = "ab"u8; + ReadOnlySpan upper = "AB"u8; + + Assert.True(ab.IsCS(lower, AsciiHash.HashCS(lower))); + Assert.False(ab.IsCS(upper, AsciiHash.HashCS(upper))); + Assert.True(ab.IsCI(lower, AsciiHash.HashUC(lower))); + Assert.True(ab.IsCI(upper, AsciiHash.HashUC(upper))); + } + + [Fact] + public void GeneratedType_abc_CaseSensitivity() + { + ReadOnlySpan lower = "abc"u8; + ReadOnlySpan upper = "ABC"u8; + + Assert.True(abc.IsCS(lower, AsciiHash.HashCS(lower))); + Assert.False(abc.IsCS(upper, AsciiHash.HashCS(upper))); + Assert.True(abc.IsCI(lower, AsciiHash.HashUC(lower))); + Assert.True(abc.IsCI(upper, AsciiHash.HashUC(upper))); + } + + [Fact] + public void GeneratedType_abcd_CaseSensitivity() + { + ReadOnlySpan lower = "abcd"u8; + ReadOnlySpan upper = "ABCD"u8; + + Assert.True(abcd.IsCS(lower, AsciiHash.HashCS(lower))); + Assert.False(abcd.IsCS(upper, AsciiHash.HashCS(upper))); + Assert.True(abcd.IsCI(lower, AsciiHash.HashUC(lower))); + Assert.True(abcd.IsCI(upper, AsciiHash.HashUC(upper))); + } + + [Fact] + public void GeneratedType_abcde_CaseSensitivity() + { + ReadOnlySpan lower = "abcde"u8; + ReadOnlySpan upper = "ABCDE"u8; + + Assert.True(abcde.IsCS(lower, AsciiHash.HashCS(lower))); + Assert.False(abcde.IsCS(upper, AsciiHash.HashCS(upper))); + Assert.True(abcde.IsCI(lower, AsciiHash.HashUC(lower))); + Assert.True(abcde.IsCI(upper, AsciiHash.HashUC(upper))); + } + + [Fact] + public void GeneratedType_abcdef_CaseSensitivity() + { + ReadOnlySpan lower = "abcdef"u8; + ReadOnlySpan upper = "ABCDEF"u8; + + Assert.True(abcdef.IsCS(lower, AsciiHash.HashCS(lower))); + Assert.False(abcdef.IsCS(upper, AsciiHash.HashCS(upper))); + Assert.True(abcdef.IsCI(lower, AsciiHash.HashUC(lower))); + Assert.True(abcdef.IsCI(upper, AsciiHash.HashUC(upper))); + } + + [Fact] + public void GeneratedType_abcdefg_CaseSensitivity() + { + ReadOnlySpan lower = "abcdefg"u8; + ReadOnlySpan upper = "ABCDEFG"u8; + + Assert.True(abcdefg.IsCS(lower, AsciiHash.HashCS(lower))); + Assert.False(abcdefg.IsCS(upper, AsciiHash.HashCS(upper))); + Assert.True(abcdefg.IsCI(lower, AsciiHash.HashUC(lower))); + Assert.True(abcdefg.IsCI(upper, AsciiHash.HashUC(upper))); + } + + [Fact] + public void GeneratedType_abcdefgh_CaseSensitivity() + { + ReadOnlySpan lower = "abcdefgh"u8; + ReadOnlySpan upper = "ABCDEFGH"u8; + + Assert.True(abcdefgh.IsCS(lower, AsciiHash.HashCS(lower))); + Assert.False(abcdefgh.IsCS(upper, AsciiHash.HashCS(upper))); + Assert.True(abcdefgh.IsCI(lower, AsciiHash.HashUC(lower))); + Assert.True(abcdefgh.IsCI(upper, AsciiHash.HashUC(upper))); + } + + [Fact] + public void GeneratedType_abcdefghijklmnopqrst_CaseSensitivity() + { + ReadOnlySpan lower = "abcdefghijklmnopqrst"u8; + ReadOnlySpan upper = "ABCDEFGHIJKLMNOPQRST"u8; + + Assert.True(abcdefghijklmnopqrst.IsCS(lower, AsciiHash.HashCS(lower))); + Assert.False(abcdefghijklmnopqrst.IsCS(upper, AsciiHash.HashCS(upper))); + Assert.True(abcdefghijklmnopqrst.IsCI(lower, AsciiHash.HashUC(lower))); + Assert.True(abcdefghijklmnopqrst.IsCI(upper, AsciiHash.HashUC(upper))); + } + + [Fact] + public void GeneratedType_foo_bar_CaseSensitivity() + { + // foo_bar is interpreted as foo-bar + ReadOnlySpan lower = "foo-bar"u8; + ReadOnlySpan upper = "FOO-BAR"u8; + + Assert.True(foo_bar.IsCS(lower, AsciiHash.HashCS(lower))); + Assert.False(foo_bar.IsCS(upper, AsciiHash.HashCS(upper))); + Assert.True(foo_bar.IsCI(lower, AsciiHash.HashUC(lower))); + Assert.True(foo_bar.IsCI(upper, AsciiHash.HashUC(upper))); + } + + [Fact] + public void GeneratedType_foo_bar_hyphen_CaseSensitivity() + { + // foo_bar_hyphen is explicitly "foo-bar" + ReadOnlySpan lower = "foo-bar"u8; + ReadOnlySpan upper = "FOO-BAR"u8; + + Assert.True(foo_bar_hyphen.IsCS(lower, AsciiHash.HashCS(lower))); + Assert.False(foo_bar_hyphen.IsCS(upper, AsciiHash.HashCS(upper))); + Assert.True(foo_bar_hyphen.IsCI(lower, AsciiHash.HashUC(lower))); + Assert.True(foo_bar_hyphen.IsCI(upper, AsciiHash.HashUC(upper))); + } + + [AsciiHash] private static partial class a { } + [AsciiHash] private static partial class ab { } + [AsciiHash] private static partial class abc { } + [AsciiHash] private static partial class abcd { } + [AsciiHash] private static partial class abcde { } + [AsciiHash] private static partial class abcdef { } + [AsciiHash] private static partial class abcdefg { } + [AsciiHash] private static partial class abcdefgh { } + + [AsciiHash] private static partial class abcdefghijklmnopqrst { } + + // show that foo_bar and foo-bar are different + [AsciiHash] private static partial class foo_bar { } + [AsciiHash("foo-bar")] private static partial class foo_bar_hyphen { } + [AsciiHash("foo_bar")] private static partial class foo_bar_underscore { } + + [AsciiHash] private static partial class 窓 { } + + [AsciiHash] private static partial class x { } + [AsciiHash] private static partial class xx { } + [AsciiHash] private static partial class xxx { } + [AsciiHash] private static partial class xxxx { } + [AsciiHash] private static partial class xxxxx { } + [AsciiHash] private static partial class xxxxxx { } + [AsciiHash] private static partial class xxxxxxx { } + [AsciiHash] private static partial class xxxxxxxx { } +} diff --git a/tests/StackExchange.Redis.Tests/FastHashTests.cs b/tests/StackExchange.Redis.Tests/FastHashTests.cs deleted file mode 100644 index a032cfc80..000000000 --- a/tests/StackExchange.Redis.Tests/FastHashTests.cs +++ /dev/null @@ -1,153 +0,0 @@ -using System; -using System.Runtime.InteropServices; -using System.Text; -using Xunit; -using Xunit.Sdk; - -#pragma warning disable CS8981, SA1134, SA1300, SA1303, SA1502 // names are weird in this test! -// ReSharper disable InconsistentNaming - to better represent expected literals -// ReSharper disable IdentifierTypo -namespace StackExchange.Redis.Tests; - -public partial class FastHashTests(ITestOutputHelper log) -{ - // note: if the hashing algorithm changes, we can update the last parameter freely; it doesn't matter - // what it *is* - what matters is that we can see that it has entropy between different values - [Theory] - [InlineData(1, a.Length, a.Text, a.Hash, 97)] - [InlineData(2, ab.Length, ab.Text, ab.Hash, 25185)] - [InlineData(3, abc.Length, abc.Text, abc.Hash, 6513249)] - [InlineData(4, abcd.Length, abcd.Text, abcd.Hash, 1684234849)] - [InlineData(5, abcde.Length, abcde.Text, abcde.Hash, 435475931745)] - [InlineData(6, abcdef.Length, abcdef.Text, abcdef.Hash, 112585661964897)] - [InlineData(7, abcdefg.Length, abcdefg.Text, abcdefg.Hash, 29104508263162465)] - [InlineData(8, abcdefgh.Length, abcdefgh.Text, abcdefgh.Hash, 7523094288207667809)] - - [InlineData(1, x.Length, x.Text, x.Hash, 120)] - [InlineData(2, xx.Length, xx.Text, xx.Hash, 30840)] - [InlineData(3, xxx.Length, xxx.Text, xxx.Hash, 7895160)] - [InlineData(4, xxxx.Length, xxxx.Text, xxxx.Hash, 2021161080)] - [InlineData(5, xxxxx.Length, xxxxx.Text, xxxxx.Hash, 517417236600)] - [InlineData(6, xxxxxx.Length, xxxxxx.Text, xxxxxx.Hash, 132458812569720)] - [InlineData(7, xxxxxxx.Length, xxxxxxx.Text, xxxxxxx.Hash, 33909456017848440)] - [InlineData(8, xxxxxxxx.Length, xxxxxxxx.Text, xxxxxxxx.Hash, 8680820740569200760)] - - [InlineData(3, 窓.Length, 窓.Text, 窓.Hash, 9677543, "窓")] - [InlineData(20, abcdefghijklmnopqrst.Length, abcdefghijklmnopqrst.Text, abcdefghijklmnopqrst.Hash, 7523094288207667809)] - - // show that foo_bar is interpreted as foo-bar - [InlineData(7, foo_bar.Length, foo_bar.Text, foo_bar.Hash, 32195221641981798, "foo-bar", nameof(foo_bar))] - [InlineData(7, foo_bar_hyphen.Length, foo_bar_hyphen.Text, foo_bar_hyphen.Hash, 32195221641981798, "foo-bar", nameof(foo_bar_hyphen))] - [InlineData(7, foo_bar_underscore.Length, foo_bar_underscore.Text, foo_bar_underscore.Hash, 32195222480842598, "foo_bar", nameof(foo_bar_underscore))] - public void Validate(int expectedLength, int actualLength, string actualValue, long actualHash, long expectedHash, string? expectedValue = null, string originForDisambiguation = "") - { - _ = originForDisambiguation; // to allow otherwise-identical test data to coexist - Assert.Equal(expectedLength, actualLength); - Assert.Equal(expectedHash, actualHash); - var bytes = Encoding.UTF8.GetBytes(actualValue); - Assert.Equal(expectedLength, bytes.Length); - Assert.Equal(expectedHash, FastHash.Hash64(bytes)); -#pragma warning disable CS0618 // Type or member is obsolete - Assert.Equal(expectedHash, FastHash.Hash64Fallback(bytes)); -#pragma warning restore CS0618 // Type or member is obsolete - if (expectedValue is not null) - { - Assert.Equal(expectedValue, actualValue); - } - } - - [Fact] - public void FastHashIs_Short() - { - ReadOnlySpan value = "abc"u8; - var hash = value.Hash64(); - Assert.Equal(abc.Hash, hash); - Assert.True(abc.Is(hash, value)); - - value = "abz"u8; - hash = value.Hash64(); - Assert.NotEqual(abc.Hash, hash); - Assert.False(abc.Is(hash, value)); - } - - [Fact] - public void FastHashIs_Long() - { - ReadOnlySpan value = "abcdefghijklmnopqrst"u8; - var hash = value.Hash64(); - Assert.Equal(abcdefghijklmnopqrst.Hash, hash); - Assert.True(abcdefghijklmnopqrst.Is(hash, value)); - - value = "abcdefghijklmnopqrsz"u8; - hash = value.Hash64(); - Assert.Equal(abcdefghijklmnopqrst.Hash, hash); // hash collision, fine - Assert.False(abcdefghijklmnopqrst.Is(hash, value)); - } - - [Fact] - public void KeyNotificationTypeFastHash_MinMaxBytes_ReflectsActualLengths() - { - // Use reflection to find all nested types in KeyNotificationTypeFastHash - var fastHashType = typeof(KeyNotificationTypeFastHash); - var nestedTypes = fastHashType.GetNestedTypes(System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); - - int? minLength = null; - int? maxLength = null; - - foreach (var nestedType in nestedTypes) - { - // Look for the Length field (generated by FastHash source generator) - var lengthField = nestedType.GetField("Length", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); - if (lengthField != null && lengthField.FieldType == typeof(int)) - { - var length = (int)lengthField.GetValue(null)!; - - if (minLength == null || length < minLength) - { - minLength = length; - } - - if (maxLength == null || length > maxLength) - { - maxLength = length; - } - } - } - - // Assert that we found at least some nested types with Length fields - Assert.NotNull(minLength); - Assert.NotNull(maxLength); - - // Assert that MinBytes and MaxBytes match the actual min/max lengths - log.WriteLine($"MinBytes: {KeyNotificationTypeFastHash.MinBytes}, MaxBytes: {KeyNotificationTypeFastHash.MaxBytes}"); - Assert.Equal(KeyNotificationTypeFastHash.MinBytes, minLength.Value); - Assert.Equal(KeyNotificationTypeFastHash.MaxBytes, maxLength.Value); - } - - [FastHash] private static partial class a { } - [FastHash] private static partial class ab { } - [FastHash] private static partial class abc { } - [FastHash] private static partial class abcd { } - [FastHash] private static partial class abcde { } - [FastHash] private static partial class abcdef { } - [FastHash] private static partial class abcdefg { } - [FastHash] private static partial class abcdefgh { } - - [FastHash] private static partial class abcdefghijklmnopqrst { } - - // show that foo_bar and foo-bar are different - [FastHash] private static partial class foo_bar { } - [FastHash("foo-bar")] private static partial class foo_bar_hyphen { } - [FastHash("foo_bar")] private static partial class foo_bar_underscore { } - - [FastHash] private static partial class 窓 { } - - [FastHash] private static partial class x { } - [FastHash] private static partial class xx { } - [FastHash] private static partial class xxx { } - [FastHash] private static partial class xxxx { } - [FastHash] private static partial class xxxxx { } - [FastHash] private static partial class xxxxxx { } - [FastHash] private static partial class xxxxxxx { } - [FastHash] private static partial class xxxxxxxx { } -} diff --git a/tests/StackExchange.Redis.Tests/GlobalUsings.cs b/tests/StackExchange.Redis.Tests/GlobalUsings.cs new file mode 100644 index 000000000..ca9c34d74 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/GlobalUsings.cs @@ -0,0 +1,3 @@ +extern alias respite; +global using AsciiHash = respite::RESPite.AsciiHash; +global using AsciiHashAttribute = respite::RESPite.AsciiHashAttribute; diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs index 60469eb49..0a70aa739 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs @@ -410,60 +410,60 @@ public void DefaultKeyNotification_HasExpectedProperties() } [Theory] - [InlineData(KeyNotificationTypeFastHash.append.Text, KeyNotificationType.Append)] - [InlineData(KeyNotificationTypeFastHash.copy.Text, KeyNotificationType.Copy)] - [InlineData(KeyNotificationTypeFastHash.del.Text, KeyNotificationType.Del)] - [InlineData(KeyNotificationTypeFastHash.expire.Text, KeyNotificationType.Expire)] - [InlineData(KeyNotificationTypeFastHash.hdel.Text, KeyNotificationType.HDel)] - [InlineData(KeyNotificationTypeFastHash.hexpired.Text, KeyNotificationType.HExpired)] - [InlineData(KeyNotificationTypeFastHash.hincrbyfloat.Text, KeyNotificationType.HIncrByFloat)] - [InlineData(KeyNotificationTypeFastHash.hincrby.Text, KeyNotificationType.HIncrBy)] - [InlineData(KeyNotificationTypeFastHash.hpersist.Text, KeyNotificationType.HPersist)] - [InlineData(KeyNotificationTypeFastHash.hset.Text, KeyNotificationType.HSet)] - [InlineData(KeyNotificationTypeFastHash.incrbyfloat.Text, KeyNotificationType.IncrByFloat)] - [InlineData(KeyNotificationTypeFastHash.incrby.Text, KeyNotificationType.IncrBy)] - [InlineData(KeyNotificationTypeFastHash.linsert.Text, KeyNotificationType.LInsert)] - [InlineData(KeyNotificationTypeFastHash.lpop.Text, KeyNotificationType.LPop)] - [InlineData(KeyNotificationTypeFastHash.lpush.Text, KeyNotificationType.LPush)] - [InlineData(KeyNotificationTypeFastHash.lrem.Text, KeyNotificationType.LRem)] - [InlineData(KeyNotificationTypeFastHash.lset.Text, KeyNotificationType.LSet)] - [InlineData(KeyNotificationTypeFastHash.ltrim.Text, KeyNotificationType.LTrim)] - [InlineData(KeyNotificationTypeFastHash.move_from.Text, KeyNotificationType.MoveFrom)] - [InlineData(KeyNotificationTypeFastHash.move_to.Text, KeyNotificationType.MoveTo)] - [InlineData(KeyNotificationTypeFastHash.persist.Text, KeyNotificationType.Persist)] - [InlineData(KeyNotificationTypeFastHash.rename_from.Text, KeyNotificationType.RenameFrom)] - [InlineData(KeyNotificationTypeFastHash.rename_to.Text, KeyNotificationType.RenameTo)] - [InlineData(KeyNotificationTypeFastHash.restore.Text, KeyNotificationType.Restore)] - [InlineData(KeyNotificationTypeFastHash.rpop.Text, KeyNotificationType.RPop)] - [InlineData(KeyNotificationTypeFastHash.rpush.Text, KeyNotificationType.RPush)] - [InlineData(KeyNotificationTypeFastHash.sadd.Text, KeyNotificationType.SAdd)] - [InlineData(KeyNotificationTypeFastHash.set.Text, KeyNotificationType.Set)] - [InlineData(KeyNotificationTypeFastHash.setrange.Text, KeyNotificationType.SetRange)] - [InlineData(KeyNotificationTypeFastHash.sortstore.Text, KeyNotificationType.SortStore)] - [InlineData(KeyNotificationTypeFastHash.srem.Text, KeyNotificationType.SRem)] - [InlineData(KeyNotificationTypeFastHash.spop.Text, KeyNotificationType.SPop)] - [InlineData(KeyNotificationTypeFastHash.xadd.Text, KeyNotificationType.XAdd)] - [InlineData(KeyNotificationTypeFastHash.xdel.Text, KeyNotificationType.XDel)] - [InlineData(KeyNotificationTypeFastHash.xgroup_createconsumer.Text, KeyNotificationType.XGroupCreateConsumer)] - [InlineData(KeyNotificationTypeFastHash.xgroup_create.Text, KeyNotificationType.XGroupCreate)] - [InlineData(KeyNotificationTypeFastHash.xgroup_delconsumer.Text, KeyNotificationType.XGroupDelConsumer)] - [InlineData(KeyNotificationTypeFastHash.xgroup_destroy.Text, KeyNotificationType.XGroupDestroy)] - [InlineData(KeyNotificationTypeFastHash.xgroup_setid.Text, KeyNotificationType.XGroupSetId)] - [InlineData(KeyNotificationTypeFastHash.xsetid.Text, KeyNotificationType.XSetId)] - [InlineData(KeyNotificationTypeFastHash.xtrim.Text, KeyNotificationType.XTrim)] - [InlineData(KeyNotificationTypeFastHash.zadd.Text, KeyNotificationType.ZAdd)] - [InlineData(KeyNotificationTypeFastHash.zdiffstore.Text, KeyNotificationType.ZDiffStore)] - [InlineData(KeyNotificationTypeFastHash.zinterstore.Text, KeyNotificationType.ZInterStore)] - [InlineData(KeyNotificationTypeFastHash.zunionstore.Text, KeyNotificationType.ZUnionStore)] - [InlineData(KeyNotificationTypeFastHash.zincr.Text, KeyNotificationType.ZIncr)] - [InlineData(KeyNotificationTypeFastHash.zrembyrank.Text, KeyNotificationType.ZRemByRank)] - [InlineData(KeyNotificationTypeFastHash.zrembyscore.Text, KeyNotificationType.ZRemByScore)] - [InlineData(KeyNotificationTypeFastHash.zrem.Text, KeyNotificationType.ZRem)] - [InlineData(KeyNotificationTypeFastHash.expired.Text, KeyNotificationType.Expired)] - [InlineData(KeyNotificationTypeFastHash.evicted.Text, KeyNotificationType.Evicted)] - [InlineData(KeyNotificationTypeFastHash._new.Text, KeyNotificationType.New)] - [InlineData(KeyNotificationTypeFastHash.overwritten.Text, KeyNotificationType.Overwritten)] - [InlineData(KeyNotificationTypeFastHash.type_changed.Text, KeyNotificationType.TypeChanged)] + [InlineData("append", KeyNotificationType.Append)] + [InlineData("copy", KeyNotificationType.Copy)] + [InlineData("del", KeyNotificationType.Del)] + [InlineData("expire", KeyNotificationType.Expire)] + [InlineData("hdel", KeyNotificationType.HDel)] + [InlineData("hexpired", KeyNotificationType.HExpired)] + [InlineData("hincrbyfloat", KeyNotificationType.HIncrByFloat)] + [InlineData("hincrby", KeyNotificationType.HIncrBy)] + [InlineData("hpersist", KeyNotificationType.HPersist)] + [InlineData("hset", KeyNotificationType.HSet)] + [InlineData("incrbyfloat", KeyNotificationType.IncrByFloat)] + [InlineData("incrby", KeyNotificationType.IncrBy)] + [InlineData("linsert", KeyNotificationType.LInsert)] + [InlineData("lpop", KeyNotificationType.LPop)] + [InlineData("lpush", KeyNotificationType.LPush)] + [InlineData("lrem", KeyNotificationType.LRem)] + [InlineData("lset", KeyNotificationType.LSet)] + [InlineData("ltrim", KeyNotificationType.LTrim)] + [InlineData("move_from", KeyNotificationType.MoveFrom)] + [InlineData("move_to", KeyNotificationType.MoveTo)] + [InlineData("persist", KeyNotificationType.Persist)] + [InlineData("rename_from", KeyNotificationType.RenameFrom)] + [InlineData("rename_to", KeyNotificationType.RenameTo)] + [InlineData("restore", KeyNotificationType.Restore)] + [InlineData("rpop", KeyNotificationType.RPop)] + [InlineData("rpush", KeyNotificationType.RPush)] + [InlineData("sadd", KeyNotificationType.SAdd)] + [InlineData("set", KeyNotificationType.Set)] + [InlineData("setrange", KeyNotificationType.SetRange)] + [InlineData("sortstore", KeyNotificationType.SortStore)] + [InlineData("srem", KeyNotificationType.SRem)] + [InlineData("spop", KeyNotificationType.SPop)] + [InlineData("xadd", KeyNotificationType.XAdd)] + [InlineData("xdel", KeyNotificationType.XDel)] + [InlineData("xgroup-createconsumer", KeyNotificationType.XGroupCreateConsumer)] + [InlineData("xgroup-create", KeyNotificationType.XGroupCreate)] + [InlineData("xgroup-delconsumer", KeyNotificationType.XGroupDelConsumer)] + [InlineData("xgroup-destroy", KeyNotificationType.XGroupDestroy)] + [InlineData("xgroup-setid", KeyNotificationType.XGroupSetId)] + [InlineData("xsetid", KeyNotificationType.XSetId)] + [InlineData("xtrim", KeyNotificationType.XTrim)] + [InlineData("zadd", KeyNotificationType.ZAdd)] + [InlineData("zdiffstore", KeyNotificationType.ZDiffStore)] + [InlineData("zinterstore", KeyNotificationType.ZInterStore)] + [InlineData("zunionstore", KeyNotificationType.ZUnionStore)] + [InlineData("zincr", KeyNotificationType.ZIncr)] + [InlineData("zrembyrank", KeyNotificationType.ZRemByRank)] + [InlineData("zrembyscore", KeyNotificationType.ZRemByScore)] + [InlineData("zrem", KeyNotificationType.ZRem)] + [InlineData("expired", KeyNotificationType.Expired)] + [InlineData("evicted", KeyNotificationType.Evicted)] + [InlineData("new", KeyNotificationType.New)] + [InlineData("overwritten", KeyNotificationType.Overwritten)] + [InlineData("type_changed", KeyNotificationType.TypeChanged)] public unsafe void FastHashParse_AllKnownValues_ParseCorrectly(string raw, KeyNotificationType parsed) { var arr = ArrayPool.Shared.Rent(Encoding.UTF8.GetMaxByteCount(raw.Length)); @@ -476,12 +476,12 @@ public unsafe void FastHashParse_AllKnownValues_ParseCorrectly(string raw, KeyNo } } - var result = KeyNotificationTypeFastHash.Parse(arr.AsSpan(0, bytes)); + var result = KeyNotificationTypeMetadata.Parse(arr.AsSpan(0, bytes)); log.WriteLine($"Parsed '{raw}' as {result}"); Assert.Equal(parsed, result); // and the other direction: - var fetchedBytes = KeyNotificationTypeFastHash.GetRawBytes(parsed); + var fetchedBytes = KeyNotificationTypeMetadata.GetRawBytes(parsed); string fetched; fixed (byte* bPtr = fetchedBytes) { diff --git a/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj b/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj index 4227fedc3..dfd7b1c09 100644 --- a/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj +++ b/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj @@ -20,6 +20,7 @@ + diff --git a/toys/KestrelRedisServer/KestrelRedisServer.csproj b/toys/KestrelRedisServer/KestrelRedisServer.csproj index 8854d6ac8..f7955c42e 100644 --- a/toys/KestrelRedisServer/KestrelRedisServer.csproj +++ b/toys/KestrelRedisServer/KestrelRedisServer.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 $(NoWarn);CS1591 enable enable diff --git a/toys/KestrelRedisServer/RedisConnectionHandler.cs b/toys/KestrelRedisServer/RedisConnectionHandler.cs index 58511b1fc..415da54b9 100644 --- a/toys/KestrelRedisServer/RedisConnectionHandler.cs +++ b/toys/KestrelRedisServer/RedisConnectionHandler.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Connections; +using System.Diagnostics; +using Microsoft.AspNetCore.Connections; using StackExchange.Redis.Server; namespace KestrelRedisServer @@ -12,7 +13,20 @@ public override Task OnConnectedAsync(ConnectionContext connection) { node = null; } - return server.RunClientAsync(connection.Transport, node: node); + + return server.RunClientAsync(connection.Transport, node: node) + .ContinueWith( + t => + { + // ensure any exceptions are observed + var ex = t.Exception; + if (ex != null) + { + Debug.WriteLine(ex.Message); + GC.KeepAlive(ex); + } + }, + TaskContinuationOptions.OnlyOnFaulted); } } } diff --git a/toys/StackExchange.Redis.Server/GlobalUsings.cs b/toys/StackExchange.Redis.Server/GlobalUsings.cs new file mode 100644 index 000000000..aa3ae0946 --- /dev/null +++ b/toys/StackExchange.Redis.Server/GlobalUsings.cs @@ -0,0 +1,22 @@ +extern alias seredis; +global using Format = seredis::StackExchange.Redis.Format; +global using PhysicalConnection = seredis::StackExchange.Redis.PhysicalConnection; +/* +During the v2/v3 transition, SE.Redis doesn't have RESPite, which +means it needs to merge in a few types like AsciiHash; this causes +conflicts; this file is a place to resolve them. Since the server +is now *mostly* RESPite, it turns out that the most efficient way +to do this is to shunt all of SE.Redis off into an alias, and bring +back just the types we need. +*/ +global using RedisChannel = seredis::StackExchange.Redis.RedisChannel; +global using RedisCommand = seredis::StackExchange.Redis.RedisCommand; +global using RedisCommandMetadata = seredis::StackExchange.Redis.RedisCommandMetadata; +global using RedisKey = seredis::StackExchange.Redis.RedisKey; +global using RedisProtocol = seredis::StackExchange.Redis.RedisProtocol; +global using RedisValue = seredis::StackExchange.Redis.RedisValue; +global using ResultType = seredis::StackExchange.Redis.ResultType; +global using ServerSelectionStrategy = seredis::StackExchange.Redis.ServerSelectionStrategy; +global using ServerType = seredis::StackExchange.Redis.ServerType; +global using SlotRange = seredis::StackExchange.Redis.SlotRange; +global using TaskSource = seredis::StackExchange.Redis.TaskSource; diff --git a/toys/StackExchange.Redis.Server/RedisClient.Output.cs b/toys/StackExchange.Redis.Server/RedisClient.Output.cs index 525dcc4e1..cfb3e1dcb 100644 --- a/toys/StackExchange.Redis.Server/RedisClient.Output.cs +++ b/toys/StackExchange.Redis.Server/RedisClient.Output.cs @@ -1,9 +1,12 @@ using System; using System.Buffers; +using System.Diagnostics; using System.IO.Pipelines; +using System.Text; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; +using RESPite.Messages; namespace StackExchange.Redis.Server; @@ -16,7 +19,13 @@ public partial class RedisClient AllowSynchronousContinuations = false, }; - private readonly Channel _replies = Channel.CreateUnbounded(s_replyChannelOptions); + private readonly struct VersionedResponse(TypedRedisValue value, RedisProtocol protocol) + { + public readonly TypedRedisValue Value = value; + public readonly RedisProtocol Protocol = protocol; + } + + private readonly Channel _replies = Channel.CreateUnbounded(s_replyChannelOptions); public void AddOutbound(in TypedRedisValue message) { @@ -28,11 +37,12 @@ public void AddOutbound(in TypedRedisValue message) try { - if (!_replies.Writer.TryWrite(message)) + var versioned = new VersionedResponse(message, Protocol); + if (!_replies.Writer.TryWrite(versioned)) { // sorry, we're going to need it, but in reality: we're using // unbounded channels, so this isn't an issue - _replies.Writer.WriteAsync(message).AsTask().Wait(); + _replies.Writer.WriteAsync(versioned).AsTask().Wait(); } } catch @@ -51,7 +61,8 @@ public ValueTask AddOutboundAsync(in TypedRedisValue message, CancellationToken try { - var pending = _replies.Writer.WriteAsync(message, cancellationToken); + var versioned = new VersionedResponse(message, Protocol); + var pending = _replies.Writer.WriteAsync(versioned, cancellationToken); if (!pending.IsCompleted) return Awaited(message, pending); pending.GetAwaiter().GetResult(); // if we succeed, the writer owns it for recycling @@ -85,13 +96,23 @@ public async Task WriteOutputAsync(PipeWriter writer, CancellationToken cancella var reader = _replies.Reader; do { - while (reader.TryRead(out var message)) + int count = 0; + while (reader.TryRead(out var versioned)) { - await RespServer.WriteResponseAsync(this, writer, message, Protocol); - message.Recycle(); + WriteResponse(writer, versioned.Value, versioned.Protocol); + versioned.Value.Recycle(); + count++; } - await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + if (count != 0) + { +#if NET9_0_OR_GREATER + Node?.Server?.OnFlush(this, count, writer.CanGetUnflushedBytes ? writer.UnflushedBytes : -1); +#else + Node?.Server?.OnFlush(this, count, -1); +#endif + await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + } } // await more data while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)); @@ -101,5 +122,139 @@ public async Task WriteOutputAsync(PipeWriter writer, CancellationToken cancella { await writer.CompleteAsync(ex); } + + static void WriteResponse(IBufferWriter output, TypedRedisValue value, RedisProtocol protocol) + { + static void WritePrefix(IBufferWriter output, char prefix) + { + var span = output.GetSpan(1); + span[0] = (byte)prefix; + output.Advance(1); + } + + if (value.IsNil) return; // not actually a request (i.e. empty/whitespace request) + + var type = value.Type; + if (protocol is RedisProtocol.Resp2 & type is not RespPrefix.Null) + { + if (type is RespPrefix.VerbatimString) + { + var s = (string)value.AsRedisValue(); + if (s is { Length: >= 4 } && s[3] == ':') + value = TypedRedisValue.BulkString(s.Substring(4)); + } + type = ToResp2(type); + } + RetryResp2: + if (protocol is RedisProtocol.Resp3 && value.IsNullValueOrArray) + { + output.Write("_\r\n"u8); + } + else + { + char prefix; + switch (type) + { + case RespPrefix.Integer: + PhysicalConnection.WriteInteger(output, (long)value.AsRedisValue()); + break; + case RespPrefix.SimpleError: + prefix = '-'; + goto BasicMessage; + case RespPrefix.SimpleString: + prefix = '+'; + BasicMessage: + WritePrefix(output, prefix); + var val = (string)value.AsRedisValue() ?? ""; + var expectedLength = Encoding.UTF8.GetByteCount(val); + PhysicalConnection.WriteRaw(output, val, expectedLength); + PhysicalConnection.WriteCrlf(output); + break; + case RespPrefix.BulkString: + PhysicalConnection.WriteBulkString(value.AsRedisValue(), output); + break; + case RespPrefix.Null: + case RespPrefix.Push when value.IsNullArray: + case RespPrefix.Map when value.IsNullArray: + case RespPrefix.Set when value.IsNullArray: + case RespPrefix.Attribute when value.IsNullArray: + output.Write("_\r\n"u8); + break; + case RespPrefix.Array when value.IsNullArray: + PhysicalConnection.WriteMultiBulkHeader(output, -1); + break; + case RespPrefix.Push: + case RespPrefix.Map: + case RespPrefix.Array: + case RespPrefix.Set: + case RespPrefix.Attribute: + var segment = value.Span; + PhysicalConnection.WriteMultiBulkHeader(output, segment.Length, ToResultType(type)); + foreach (var item in segment) + { + if (item.IsNil) throw new InvalidOperationException("Array element cannot be nil"); + WriteResponse(output, item, protocol); + } + break; + default: + // retry with RESP2 + var r2 = ToResp2(type); + if (r2 != type) + { + Debug.WriteLine($"{type} not handled in RESP3; using {r2} instead"); + goto RetryResp2; + } + + throw new InvalidOperationException( + "Unexpected result type: " + value.Type); + } + } + + static RespPrefix ToResp2(RespPrefix type) + { + switch (type) + { + case RespPrefix.Boolean: + return RespPrefix.Integer; + case RespPrefix.Double: + case RespPrefix.BigInteger: + return RespPrefix.SimpleString; + case RespPrefix.BulkError: + return RespPrefix.SimpleError; + case RespPrefix.VerbatimString: + return RespPrefix.BulkString; + case RespPrefix.Map: + case RespPrefix.Set: + case RespPrefix.Push: + case RespPrefix.Attribute: + return RespPrefix.Array; + default: return type; + } + } + + static ResultType ToResultType(RespPrefix type) => + type switch + { + RespPrefix.None => ResultType.None, + RespPrefix.SimpleString => ResultType.SimpleString, + RespPrefix.SimpleError => ResultType.Error, + RespPrefix.Integer => ResultType.Integer, + RespPrefix.BulkString => ResultType.BulkString, + RespPrefix.Array => ResultType.Array, + RespPrefix.Null => ResultType.Null, + RespPrefix.Boolean => ResultType.Boolean, + RespPrefix.Double => ResultType.Double, + RespPrefix.BigInteger => ResultType.BigInteger, + RespPrefix.BulkError => ResultType.BlobError, + RespPrefix.VerbatimString => ResultType.VerbatimString, + RespPrefix.Map => ResultType.Map, + RespPrefix.Set => ResultType.Set, + RespPrefix.Push => ResultType.Push, + RespPrefix.Attribute => ResultType.Attribute, + // StreamContinuation and StreamTerminator don't have direct ResultType equivalents + // These are protocol-level markers, not result types + _ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unexpected RespPrefix value"), + }; + } } } diff --git a/toys/StackExchange.Redis.Server/RedisClient.cs b/toys/StackExchange.Redis.Server/RedisClient.cs index 56ecd0dbb..bb930d5f9 100644 --- a/toys/StackExchange.Redis.Server/RedisClient.cs +++ b/toys/StackExchange.Redis.Server/RedisClient.cs @@ -1,22 +1,94 @@ using System; +using System.Buffers; using System.Collections.Generic; using System.IO.Pipelines; using System.Text; +using RESPite; +using RESPite.Messages; namespace StackExchange.Redis.Server { public partial class RedisClient(RedisServer.Node node) : IDisposable +#pragma warning disable SA1001 + #if NET6_0_OR_GREATER + , ISpanFormattable +#else + , IFormattable + #endif +#pragma warning restore SA1001 { + private RespScanState _readState; + + public override string ToString() + { + if (Protocol is RedisProtocol.Resp2) + { + return IsSubscriber ? $"{Id}:sub" : Id.ToString(); + } + return $"{Id}:r3"; + } + + string IFormattable.ToString(string format, IFormatProvider formatProvider) => ToString(); +#if NET6_0_OR_GREATER + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider provider) + { + if (!Id.TryFormat(destination, out charsWritten)) + { + return false; + } + destination = destination.Slice(charsWritten); + if (Protocol is RedisProtocol.Resp2) + { + if (IsSubscriber) + { + if (!":sub".AsSpan().TryCopyTo(destination)) + { + return false; + } + charsWritten += 4; + } + } + else + { + if (!":r3".AsSpan().TryCopyTo(destination)) + { + return false; + } + charsWritten += 3; + } + return true; + } +#endif + + public bool TryReadRequest(ReadOnlySequence data, out long consumed) + { + // skip past data we've already read + data = data.Slice(_readState.TotalBytes); + var status = RespFrameScanner.Default.TryRead(ref _readState, data); + consumed = _readState.TotalBytes; + switch (status) + { + case OperationStatus.Done: + _readState = default; // reset ready for the next frame + return true; + case OperationStatus.NeedMoreData: + consumed = 0; + return false; + default: + throw new InvalidOperationException($"Unexpected status: {status}"); + } + } + public RedisServer.Node Node => node; internal int SkipReplies { get; set; } internal bool ShouldSkipResponse() { - if (SkipReplies > 0) + if (SkipReplies > 0) // skips N { SkipReplies--; return true; } - return false; + return SkipReplies < 0; // skips forever } public int Database { get; set; } @@ -41,6 +113,8 @@ public void Dispose() try { pipe.Output.Complete(); } catch { } if (pipe is IDisposable d) try { d.Dispose(); } catch { } } + + _readState = default; } private int _activeSlot = ServerSelectionStrategy.NoSlot; @@ -220,13 +294,12 @@ public ExecResult FlushMulti(out byte[][] commands) // completely unoptimized for now; this is fine private List _transaction; // null until needed - internal bool BufferMulti(in RedisRequest request, in CommandBytes command) + internal bool BufferMulti(in RedisRequest request, in AsciiHash command) { switch (_transactionState) { case TransactionState.MultiHopeful when !AllowInTransaction(command): - // TODO we also can't do this bit! just store the command name for now - (_transaction ??= []).Add(Encoding.ASCII.GetBytes(request.GetString(0))); + (_transaction ??= []).Add(request.Serialize()); return true; case TransactionState.MultiAbortByError when !AllowInTransaction(command): case TransactionState.MultiDoomedByTouch when !AllowInTransaction(command): @@ -236,12 +309,12 @@ internal bool BufferMulti(in RedisRequest request, in CommandBytes command) return false; } - static bool AllowInTransaction(in CommandBytes cmd) + static bool AllowInTransaction(in AsciiHash cmd) => cmd.Equals(EXEC) || cmd.Equals(DISCARD) || cmd.Equals(MULTI) || cmd.Equals(WATCH) || cmd.Equals(UNWATCH); } - private static readonly CommandBytes + private static readonly AsciiHash EXEC = new("EXEC"u8), DISCARD = new("DISCARD"u8), MULTI = new("MULTI"u8), WATCH = new("WATCH"u8), UNWATCH = new("UNWATCH"u8); } diff --git a/toys/StackExchange.Redis.Server/RedisRequest.cs b/toys/StackExchange.Redis.Server/RedisRequest.cs index d8ea13b86..269e31d9a 100644 --- a/toys/StackExchange.Redis.Server/RedisRequest.cs +++ b/toys/StackExchange.Redis.Server/RedisRequest.cs @@ -1,14 +1,14 @@ using System; +using System.Buffers; +using System.Diagnostics; +using RESPite; +using RESPite.Messages; namespace StackExchange.Redis.Server { public readonly ref struct RedisRequest { - // why ref? don't *really* need it, but: these things are "in flight" - // based on an open RawResult (which is just the detokenized ReadOnlySequence) - // so: using "ref" makes it clear that you can't expect to store these and have - // them keep working - private readonly RawResult _inner; + private readonly RespReader _rootReader; private readonly RedisClient _client; public RedisRequest WithClient(RedisClient client) => new(in this, client); @@ -30,63 +30,113 @@ public TypedRedisValue CommandNotFound() public TypedRedisValue UnknownSubcommandOrArgumentCount() => TypedRedisValue.Error($"ERR Unknown subcommand or wrong number of arguments for '{ToString()}'."); - public string GetString(int index) - => _inner[index].GetString(); + public string GetString(int index) => GetReader(index).ReadString(); - public bool IsString(int index, string value) // TODO: optimize - => string.Equals(value, _inner[index].GetString(), StringComparison.OrdinalIgnoreCase); + [Obsolete("Use IsString(int, ReadOnlySpan{byte}) instead.")] + public bool IsString(int index, string value) + => GetReader(index).Is(value); + + public bool IsString(int index, ReadOnlySpan value) + => GetReader(index).Is(value); public override int GetHashCode() => throw new NotSupportedException(); - internal RedisRequest(scoped in RawResult result) - { - _inner = result; - Count = result.ItemsCount; - } - public RedisValue GetValue(int index) - => _inner[index].AsRedisValue(); + /// + /// Get a reader initialized at the start of the payload. + /// + public RespReader GetRootReader() => _rootReader; - public int GetInt32(int index) - => (int)_inner[index].AsRedisValue(); + /// + /// Get a reader initialized at the start of the payload. + /// + public RespReader GetReader(int childIndex) + { + if (childIndex < 0 || childIndex >= Count) Throw(); + var reader = GetRootReader(); + reader.MoveNextAggregate(); + for (int i = 0; i < childIndex; i++) + { + reader.MoveNextScalar(); + } + reader.MoveNextScalar(); + return reader; - public bool TryGetInt64(int index, out long value) - => _inner[index].TryGetInt64(out value); - public bool TryGetInt32(int index, out int value) + static void Throw() => throw new ArgumentOutOfRangeException(nameof(childIndex)); + } + + internal RedisRequest(scoped in RespReader reader, ref byte[] commandLease) { - if (_inner[index].TryGetInt64(out var tmp)) + _rootReader = reader; + var local = reader; + if (local.TryMoveNext(checkError: false) & local.IsAggregate) + { + Count = local.AggregateLength(); + } + + if (Count == 0) + { + Command = s_EmptyCommand; + KnownCommand = RedisCommand.UNKNOWN; + } + else { - value = (int)tmp; - if (value == tmp) return true; + local.MoveNextScalar(); + unsafe + { + KnownCommand = local.TryParseScalar(&RedisCommandMetadata.TryParseCI, out RedisCommand cmd) + ? cmd : RedisCommand.UNKNOWN; + } + var len = local.ScalarLength(); + if (len > commandLease.Length) + { + ArrayPool.Shared.Return(commandLease); + commandLease = ArrayPool.Shared.Rent(len); + } + var readBytes = local.CopyTo(commandLease); + Debug.Assert(readBytes == len); + AsciiHash.ToUpper(commandLease.AsSpan(0, readBytes)); + // note we retain the lease array in the Command, this is intentional + Command = new(commandLease, 0, readBytes); } + } + + internal RedisCommand KnownCommand { get; } - value = 0; - return false; + internal static byte[] GetLease() => ArrayPool.Shared.Rent(16); + internal static void ReleaseLease(ref byte[] commandLease) + { + ArrayPool.Shared.Return(commandLease); + commandLease = []; } - public long GetInt64(int index) => (long)_inner[index].AsRedisValue(); + private static readonly AsciiHash s_EmptyCommand = new(Array.Empty()); + + public readonly AsciiHash Command; + + public RedisValue GetValue(int index) => GetReader(index).ReadRedisValue(); + + public bool TryGetInt64(int index, out long value) => GetReader(index).TryReadInt64(out value); + + public bool TryGetInt32(int index, out int value) => GetReader(index).TryReadInt32(out value); + + public int GetInt32(int index) => GetReader(index).ReadInt32(); + + public long GetInt64(int index) => GetReader(index).ReadInt64(); public RedisKey GetKey(int index, KeyFlags flags = KeyFlags.None) { - var key = _inner[index].AsRedisKey(); + var key = GetReader(index).ReadRedisKey(); _client?.OnKey(key, flags); return key; } internal RedisChannel GetChannel(int index, RedisChannel.RedisChannelOptions options) - => _inner[index].AsRedisChannel(null, options); + => GetReader(index).ReadRedisChannel(options); - internal bool TryGetCommandBytes(int i, out CommandBytes command) - { - var payload = _inner[i].Payload; - if (payload.Length > CommandBytes.MaxLength) - { - command = default; - return false; - } + internal RedisRequest(ReadOnlySpan payload, ref byte[] commandLease) : this(new RespReader(payload), ref commandLease) { } + internal RedisRequest(in ReadOnlySequence payload, ref byte[] commandLease) : this(new RespReader(payload), ref commandLease) { } - command = payload.IsEmpty ? default : new CommandBytes(payload); - return true; - } + public byte[] Serialize() => _rootReader.Serialize(); } [Flags] diff --git a/toys/StackExchange.Redis.Server/RedisServer.PubSub.cs b/toys/StackExchange.Redis.Server/RedisServer.PubSub.cs index 140dd3caa..7778ed63b 100644 --- a/toys/StackExchange.Redis.Server/RedisServer.PubSub.cs +++ b/toys/StackExchange.Redis.Server/RedisServer.PubSub.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Text.RegularExpressions; using System.Threading; +using RESPite.Messages; namespace StackExchange.Redis.Server; @@ -182,7 +183,7 @@ public int Publish(in RedisChannel channel, in RedisValue value) // we can do simple and sharded equality lookups directly if ((simpleCount + shardedCount) != 0 && subs.TryGetValue(channel, out _)) { - var msg = TypedRedisValue.Rent(3, out var span, ResultType.Push); + var msg = TypedRedisValue.Rent(3, out var span, PushKind); span[0] = TypedRedisValue.BulkString(channel.IsSharded ? "smessage" : "message"); span[1] = TypedRedisValue.BulkString(channel); span[2] = TypedRedisValue.BulkString(value); @@ -198,7 +199,7 @@ public int Publish(in RedisChannel channel, in RedisValue value) { if (pair.Key.IsPattern && pair.Value is { } glob && glob.IsMatch(channelName)) { - var msg = TypedRedisValue.Rent(4, out var span, ResultType.Push); + var msg = TypedRedisValue.Rent(4, out var span, PushKind); span[0] = TypedRedisValue.BulkString("pmessage"); span[1] = TypedRedisValue.BulkString(pair.Key); span[2] = TypedRedisValue.BulkString(channel); @@ -213,11 +214,15 @@ public int Publish(in RedisChannel channel, in RedisValue value) return count; } - private void SendMessage(string kind, RedisChannel channel, int count) + public bool IsResp2 => Protocol is RedisProtocol.Resp2; + + public RespPrefix PushKind => IsResp2 ? RespPrefix.Array : RespPrefix.Push; + + private void SendSubUnsubMessage(string kind, RedisChannel channel, int count) { if (Node is { } node) { - var reply = TypedRedisValue.Rent(3, out var span, ResultType.Push); + var reply = TypedRedisValue.Rent(3, out var span, PushKind); span[0] = TypedRedisValue.BulkString(kind); span[1] = TypedRedisValue.BulkString((byte[])channel); span[2] = TypedRedisValue.Integer(count); @@ -226,20 +231,34 @@ private void SendMessage(string kind, RedisChannel channel, int count) } } + private ref int GetCountField(RedisChannel channel) + => ref channel.IsSharded ? ref shardedCount + : ref channel.IsPattern ? ref patternCount + : ref simpleCount; + internal void Subscribe(RedisChannel channel) { Regex glob = channel.IsPattern ? BuildGlob(channel) : null; var subs = Subscriptions; int count; + ref int field = ref GetCountField(channel); lock (subs) { - if (subs.ContainsKey(channel)) return; - subs.Add(channel, glob); - count = channel.IsSharded ? ++shardedCount - : channel.IsPattern ? ++patternCount - : ++simpleCount; + #if NET + count = subs.TryAdd(channel, glob) ? ++field : field; + #else + if (subs.ContainsKey(channel)) + { + count = field; + } + else + { + subs.Add(channel, glob); + count = ++field; + } + #endif } - SendMessage( + SendSubUnsubMessage( channel.IsSharded ? "ssubscribe" : channel.IsPattern ? "psubscribe" : "subscribe", @@ -266,14 +285,12 @@ internal void Unsubscribe(RedisChannel channel) var subs = SubscriptionsIfAny; if (subs is null) return; int count; + ref int field = ref GetCountField(channel); lock (subs) { - if (!subs.Remove(channel)) return; - count = channel.IsSharded ? --shardedCount - : channel.IsPattern ? --patternCount - : --simpleCount; + count = subs.Remove(channel) ? --field : field; } - SendMessage( + SendSubUnsubMessage( channel.IsSharded ? "sunsubscribe" : channel.IsPattern ? "punsubscribe" : "unsubscribe", @@ -332,7 +349,7 @@ internal void UnsubscribeAll(RedisCommand cmd) } foreach (var key in remove.AsSpan(0, count)) { - SendMessage(msg, key, 0); + SendSubUnsubMessage(msg, key, 0); } ArrayPool.Shared.Return(remove); } diff --git a/toys/StackExchange.Redis.Server/RedisServer.cs b/toys/StackExchange.Redis.Server/RedisServer.cs index 1e92aa8ec..8d7e344bb 100644 --- a/toys/StackExchange.Redis.Server/RedisServer.cs +++ b/toys/StackExchange.Redis.Server/RedisServer.cs @@ -7,6 +7,8 @@ using System.Net; using System.Text; using System.Threading; +using RESPite; +using RESPite.Messages; namespace StackExchange.Redis.Server { @@ -155,57 +157,26 @@ protected override void AppendStats(StringBuilder sb) public override TypedRedisValue Execute(RedisClient client, in RedisRequest request) { - if (request.Count != 0) + var pw = Password; + if (pw.Length != 0 & !client.IsAuthenticated) { - var pw = Password; - if (pw.Length != 0 & !client.IsAuthenticated) - { - if (!Literals.IsAuthCommand(in request)) - return TypedRedisValue.Error("NOAUTH Authentication required."); - } - else if (client.Protocol is RedisProtocol.Resp2 && client.IsSubscriber && - !Literals.IsPubSubCommand(in request, out var cmd)) - { - return TypedRedisValue.Error( - $"ERR only (P|S)SUBSCRIBE / (P|S)UNSUBSCRIBE / PING / QUIT allowed in this context (got: '{cmd}')"); - } + if (!IsAuthCommand(request.KnownCommand)) + return TypedRedisValue.Error("NOAUTH Authentication required."); } - return base.Execute(client, request); - } - - internal class Literals - { - public static readonly CommandBytes - AUTH = new("AUTH"u8), - HELLO = new("HELLO"u8), - SETNAME = new("SETNAME"u8), - QUIT = new("SETNAME"u8), - PING = new("PING"u8), - SUBSCRIBE = new("SUBSCRIBE"u8), - PSUBSCRIBE = new("PSUBSCRIBE"u8), - SSUBSCRIBE = new("SSUBSCRIBE"u8), - UNSUBSCRIBE = new("UNSUBSCRIBE"u8), - PUNSUBSCRIBE = new("PUNSUBSCRIBE"u8), - SUNSUBSCRIBE = new("SUNSUBSCRIBE"u8); - - public static bool IsAuthCommand(in RedisRequest request) => - request.Count != 0 && request.TryGetCommandBytes(0, out var command) - && (command.Equals(AUTH) || command.Equals(HELLO)); - - public static bool IsPubSubCommand(in RedisRequest request, out string badCommand) + else if (client.Protocol is RedisProtocol.Resp2 && client.IsSubscriber && + !IsPubSubCommand(request.KnownCommand)) { - badCommand = ""; - if (request.Count == 0 || !request.TryGetCommandBytes(0, out var command)) - { - if (request.Count != 0) badCommand = request.GetString(0); - return false; - } - - return command.Equals(SUBSCRIBE) || command.Equals(UNSUBSCRIBE) - || command.Equals(SSUBSCRIBE) || command.Equals(SUNSUBSCRIBE) - || command.Equals(PSUBSCRIBE) || command.Equals(PUNSUBSCRIBE) - || command.Equals(PING) || command.Equals(QUIT); + return TypedRedisValue.Error( + $"ERR only [P|S][UN]SUBSCRIBE / PING / QUIT allowed in this context (got: '{request.Command}')"); } + return base.Execute(client, request); + + static bool IsAuthCommand(RedisCommand cmd) => cmd is RedisCommand.AUTH or RedisCommand.HELLO; + static bool IsPubSubCommand(RedisCommand cmd) + => cmd is RedisCommand.SUBSCRIBE or RedisCommand.UNSUBSCRIBE + or RedisCommand.SSUBSCRIBE or RedisCommand.SUNSUBSCRIBE + or RedisCommand.PSUBSCRIBE or RedisCommand.PUNSUBSCRIBE + or RedisCommand.PING or RedisCommand.QUIT; } [RedisCommand(2)] @@ -239,28 +210,37 @@ protected virtual TypedRedisValue Hello(RedisClient client, in RedisRequest requ default: return TypedRedisValue.Error("NOPROTO unsupported protocol version"); } + static TypedRedisValue ArgFail(in RespReader reader) => TypedRedisValue.Error($"ERR Syntax error in HELLO option '{reader.ReadString()}'\""); - for (int i = 2; i < request.Count && request.TryGetCommandBytes(i, out var key); i++) + for (int i = 2; i < request.Count; i++) { int remaining = request.Count - (i + 1); - TypedRedisValue ArgFail() => TypedRedisValue.Error($"ERR Syntax error in HELLO option '{key.ToString().ToLower()}'\""); - if (key.Equals(Literals.AUTH)) + var fieldReader = request.GetReader(i); + HelloSubFields field; + unsafe { - if (remaining < 2) return ArgFail(); - // ignore username for now - var pw = request.GetString(i + 2); - if (pw != Password) return TypedRedisValue.Error("WRONGPASS invalid username-password pair or user is disabled."); - isAuthed = true; - i += 2; - } - else if (key.Equals(Literals.SETNAME)) - { - if (remaining < 1) return ArgFail(); - name = request.GetString(++i); + if (!fieldReader.TryParseScalar(&HelloSubFieldsMetadata.TryParseCI, out field)) + { + return ArgFail(fieldReader); + } } - else + + switch (field) { - return ArgFail(); + case HelloSubFields.Auth: + if (remaining < 2) return ArgFail(fieldReader); + // ignore username for now + var pw = request.GetString(i + 2); + if (pw != Password) return TypedRedisValue.Error("WRONGPASS invalid username-password pair or user is disabled."); + isAuthed = true; + i += 2; + break; + case HelloSubFields.SetName: + if (remaining < 1) return ArgFail(fieldReader); + name = request.GetString(++i); + break; + default: + return ArgFail(fieldReader); } } } @@ -270,7 +250,7 @@ protected virtual TypedRedisValue Hello(RedisClient client, in RedisRequest requ client.IsAuthenticated = isAuthed; client.Name = name; - var reply = TypedRedisValue.Rent(14, out var span, ResultType.Map); + var reply = TypedRedisValue.Rent(14, out var span, RespPrefix.Map); span[0] = TypedRedisValue.BulkString("server"); span[1] = TypedRedisValue.BulkString("redis"); span[2] = TypedRedisValue.BulkString("version"); @@ -284,7 +264,7 @@ protected virtual TypedRedisValue Hello(RedisClient client, in RedisRequest requ span[10] = TypedRedisValue.BulkString("role"); span[11] = TypedRedisValue.BulkString("master"); span[12] = TypedRedisValue.BulkString("modules"); - span[13] = TypedRedisValue.EmptyArray(ResultType.Array); + span[13] = TypedRedisValue.EmptyArray(RespPrefix.Array); return reply; } @@ -381,7 +361,6 @@ protected virtual TypedRedisValue Watch(RedisClient client, in RedisRequest requ if (!client.Watch(key)) return TypedRedisValue.Error("WATCH inside MULTI is not allowed"); } - return TypedRedisValue.OK; } @@ -412,21 +391,27 @@ protected virtual TypedRedisValue Exec(RedisClient client, in RedisRequest reque case RedisClient.ExecResult.NotInTransaction: return TypedRedisValue.Error("EXEC without MULTI"); case RedisClient.ExecResult.WatchConflict: - return TypedRedisValue.NullArray(ResultType.Array); + return TypedRedisValue.NullArray(RespPrefix.Array); case RedisClient.ExecResult.AbortedByError: return TypedRedisValue.Error("EXECABORT Transaction discarded because of previous errors."); } Debug.Assert(exec is RedisClient.ExecResult.CommandsReturned); - var results = TypedRedisValue.Rent(commands.Length, out var span, ResultType.Array); + var results = TypedRedisValue.Rent(commands.Length, out var span, RespPrefix.Array); int index = 0; - foreach (var cmd in commands) + var lease = RedisRequest.GetLease(); + try { - // TODO:this is the bit we can't do just yet, until we can freely parse results - // RedisRequest inner = // ... - // inner = inner.WithClient(client); - // results[index++] = Execute(client, cmd); - span[index++] = TypedRedisValue.Error($"ERR transactions not yet implemented, sorry; ignoring {Encoding.ASCII.GetString(cmd)}"); + foreach (var cmd in commands) + { + RedisRequest inner = new(cmd, ref lease); + inner = inner.WithClient(client); + span[index++] = Execute(client, inner); + } + } + finally + { + RedisRequest.ReleaseLease(ref lease); } return results; } @@ -437,31 +422,35 @@ protected virtual void SetEx(int database, in RedisKey key, TimeSpan timeout, in Expire(database, key, timeout); } - [RedisCommand(3, "client", "setname", LockFree = true)] + [RedisCommand(3, nameof(RedisCommand.CLIENT), "setname", LockFree = true)] protected virtual TypedRedisValue ClientSetname(RedisClient client, in RedisRequest request) { client.Name = request.GetString(2); return TypedRedisValue.OK; } - [RedisCommand(2, "client", "getname", LockFree = true)] + [RedisCommand(2, nameof(RedisCommand.CLIENT), "getname", LockFree = true)] protected virtual TypedRedisValue ClientGetname(RedisClient client, in RedisRequest request) => TypedRedisValue.BulkString(client.Name); - [RedisCommand(3, "client", "reply", LockFree = true)] + [RedisCommand(3, nameof(RedisCommand.CLIENT), "reply", LockFree = true)] protected virtual TypedRedisValue ClientReply(RedisClient client, in RedisRequest request) { - if (request.IsString(2, "on")) client.SkipReplies = -1; // reply to nothing - else if (request.IsString(2, "off")) client.SkipReplies = 0; // reply to everything - else if (request.IsString(2, "skip")) client.SkipReplies = 2; // this one, and the next one + if (request.IsString(2, "on"u8)) client.SkipReplies = -1; // reply to nothing + else if (request.IsString(2, "off"u8)) client.SkipReplies = 0; // reply to everything + else if (request.IsString(2, "skip"u8)) client.SkipReplies = 2; // this one, and the next one else return TypedRedisValue.Error("ERR syntax error"); return TypedRedisValue.OK; } - [RedisCommand(2, "client", "id", LockFree = true)] + [RedisCommand(2, nameof(RedisCommand.CLIENT), "id", LockFree = true)] protected virtual TypedRedisValue ClientId(RedisClient client, in RedisRequest request) => TypedRedisValue.Integer(client.Id); + [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 + private bool IsClusterEnabled(out TypedRedisValue fault) { if (ServerType == ServerType.Cluster) @@ -507,21 +496,21 @@ protected virtual TypedRedisValue ClusterSlots(RedisClient client, in RedisReque { count += pair.Value.Slots.Length; } - var slots = TypedRedisValue.Rent(count, out var slotsSpan, ResultType.Array); + var slots = TypedRedisValue.Rent(count, out var slotsSpan, RespPrefix.Array); foreach (var pair in _nodes.OrderBy(x => x.Key, EndPointComparer.Instance)) { string host = GetHost(pair.Key, out int port); foreach (var range in pair.Value.Slots) { if (index >= count) break; // someone changed things while we were working - slotsSpan[index++] = TypedRedisValue.Rent(3, out var slotSpan, ResultType.Array); + slotsSpan[index++] = TypedRedisValue.Rent(3, out var slotSpan, RespPrefix.Array); slotSpan[0] = TypedRedisValue.Integer(range.From); slotSpan[1] = TypedRedisValue.Integer(range.To); - slotSpan[2] = TypedRedisValue.Rent(4, out var nodeSpan, ResultType.Array); + slotSpan[2] = TypedRedisValue.Rent(4, out var nodeSpan, RespPrefix.Array); nodeSpan[0] = TypedRedisValue.BulkString(host); nodeSpan[1] = TypedRedisValue.Integer(port); nodeSpan[2] = TypedRedisValue.BulkString(pair.Value.Id); - nodeSpan[3] = TypedRedisValue.EmptyArray(ResultType.Array); + nodeSpan[3] = TypedRedisValue.EmptyArray(RespPrefix.Array); } } return slots; @@ -602,6 +591,7 @@ public override string ToString() private SlotRange[] _slots; private readonly RedisServer _server; + public RedisServer Server => _server; public Node(RedisServer server, EndPoint endpoint) { Host = GetHost(endpoint, out var port); @@ -845,12 +835,12 @@ protected virtual TypedRedisValue LRange(RedisClient client, in RedisRequest req long start = request.GetInt64(2), stop = request.GetInt64(3); var len = Llen(client.Database, key); - if (len == 0) return TypedRedisValue.EmptyArray(ResultType.Array); + if (len == 0) return TypedRedisValue.EmptyArray(RespPrefix.Array); if (start < 0) start = len + start; if (stop < 0) stop = len + stop; - if (stop < 0 || start >= len || stop < start) return TypedRedisValue.EmptyArray(ResultType.Array); + if (stop < 0 || start >= len || stop < start) return TypedRedisValue.EmptyArray(RespPrefix.Array); if (start < 0) start = 0; else if (start >= len) start = len - 1; @@ -858,7 +848,7 @@ protected virtual TypedRedisValue LRange(RedisClient client, in RedisRequest req if (stop < 0) stop = 0; else if (stop >= len) stop = len - 1; - var arr = TypedRedisValue.Rent(checked((int)((stop - start) + 1)), out var span, ResultType.Array); + var arr = TypedRedisValue.Rent(checked((int)((stop - start) + 1)), out var span, RespPrefix.Array); LRange(client.Database, key, start, span); return arr; } @@ -895,7 +885,7 @@ internal int CountMatch(string pattern) return count; } } - [RedisCommand(3, "config", "get", LockFree = true)] + [RedisCommand(3, nameof(RedisCommand.CONFIG), "get", LockFree = true)] protected virtual TypedRedisValue Config(RedisClient client, in RedisRequest request) { var pattern = request.GetString(2); @@ -903,9 +893,9 @@ protected virtual TypedRedisValue Config(RedisClient client, in RedisRequest req OnUpdateServerConfiguration(); var config = ServerConfiguration; var matches = config.CountMatch(pattern); - if (matches == 0) return TypedRedisValue.EmptyArray(ResultType.Map); + if (matches == 0) return TypedRedisValue.EmptyArray(RespPrefix.Map); - var arr = TypedRedisValue.Rent(2 * matches, out var span, ResultType.Map); + var arr = TypedRedisValue.Rent(2 * matches, out var span, RespPrefix.Map); int index = 0; foreach (var pair in config.Wrapped) { @@ -1095,8 +1085,8 @@ protected virtual TypedRedisValue Keys(RedisClient client, in RedisRequest reque if (found == null) found = new List(); found.Add(TypedRedisValue.BulkString(key.AsRedisValue())); } - if (found == null) return TypedRedisValue.EmptyArray(ResultType.Array); - return TypedRedisValue.MultiBulk(found, ResultType.Array); + if (found == null) return TypedRedisValue.EmptyArray(RespPrefix.Array); + return TypedRedisValue.MultiBulk(found, RespPrefix.Array); } protected virtual IEnumerable Keys(int database, in RedisKey pattern) => throw new NotSupportedException(); @@ -1181,7 +1171,7 @@ StringBuilder AddHeader() } } - [RedisCommand(2, "memory", "purge")] + [RedisCommand(2, nameof(RedisCommand.MEMORY), "purge")] protected virtual TypedRedisValue MemoryPurge(RedisClient client, in RedisRequest request) { GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced); @@ -1191,7 +1181,7 @@ protected virtual TypedRedisValue MemoryPurge(RedisClient client, in RedisReques protected virtual TypedRedisValue Mget(RedisClient client, in RedisRequest request) { int argCount = request.Count; - var arr = TypedRedisValue.Rent(argCount - 1, out var span, ResultType.Map); + var arr = TypedRedisValue.Rent(argCount - 1, out var span, RespPrefix.Map); var db = client.Database; for (int i = 1; i < argCount; i++) { @@ -1214,9 +1204,9 @@ protected virtual TypedRedisValue Mset(RedisClient client, in RedisRequest reque [RedisCommand(-1, LockFree = true, MaxArgs = 2)] protected virtual TypedRedisValue Ping(RedisClient client, in RedisRequest request) { - if (client.IsSubscriber) + if (client.IsResp2 & client.IsSubscriber) { - var reply = TypedRedisValue.Rent(2, out var span, ResultType.Array); + var reply = TypedRedisValue.Rent(2, out var span, RespPrefix.Array); span[0] = TypedRedisValue.BulkString("pong"); RedisValue value = request.Count == 1 ? RedisValue.Null : request.GetValue(1); span[1] = TypedRedisValue.BulkString(value); @@ -1236,10 +1226,10 @@ protected virtual TypedRedisValue Quit(RedisClient client, in RedisRequest reque [RedisCommand(1, LockFree = true)] protected virtual TypedRedisValue Role(RedisClient client, in RedisRequest request) { - var arr = TypedRedisValue.Rent(3, out var span, ResultType.Array); + var arr = TypedRedisValue.Rent(3, out var span, RespPrefix.Array); span[0] = TypedRedisValue.BulkString("master"); span[1] = TypedRedisValue.Integer(0); - span[2] = TypedRedisValue.EmptyArray(ResultType.Array); + span[2] = TypedRedisValue.EmptyArray(RespPrefix.Array); return arr; } @@ -1263,7 +1253,7 @@ protected virtual TypedRedisValue Time(RedisClient client, in RedisRequest reque var ticks = delta.Ticks; var seconds = ticks / TimeSpan.TicksPerSecond; var micros = (ticks % TimeSpan.TicksPerSecond) / (TimeSpan.TicksPerMillisecond / 1000); - var reply = TypedRedisValue.Rent(2, out var span, ResultType.Array); + var reply = TypedRedisValue.Rent(2, out var span, RespPrefix.Array); span[0] = TypedRedisValue.BulkString(seconds); span[1] = TypedRedisValue.BulkString(micros); return reply; @@ -1295,5 +1285,25 @@ protected virtual long IncrBy(int database, in RedisKey key, long delta) Set(database, key, value); return value; } + + public virtual void OnFlush(RedisClient client, int messages, long bytes) + { + } + } + + internal static partial class HelloSubFieldsMetadata + { + [AsciiHash(CaseSensitive = false)] + public static partial bool TryParseCI(ReadOnlySpan command, out HelloSubFields value); + } + + internal enum HelloSubFields + { + [AsciiHash("")] + None = 0, + [AsciiHash("AUTH")] + Auth, + [AsciiHash("SETNAME")] + SetName, } } diff --git a/toys/StackExchange.Redis.Server/RespReaderExtensions.cs b/toys/StackExchange.Redis.Server/RespReaderExtensions.cs new file mode 100644 index 000000000..e4a6df46f --- /dev/null +++ b/toys/StackExchange.Redis.Server/RespReaderExtensions.cs @@ -0,0 +1,211 @@ +#nullable enable +extern alias seredis; +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using RESPite.Messages; + +namespace StackExchange.Redis; // this really belongs in SE.Redis, will be moved in v3 + +internal static class RespReaderExtensions +{ + extension(in RespReader reader) + { + public RedisValue ReadRedisValue() + { + reader.DemandScalar(); + if (reader.IsNull) return RedisValue.Null; + + return reader.Prefix switch + { + RespPrefix.Boolean => reader.ReadBoolean(), + RespPrefix.Integer => reader.ReadInt64(), + _ => reader.ReadByteArray(), + }; + } + + public string DebugReadTruncatedString(int maxChars) + { + if (!reader.IsScalar) return ""; + try + { + var s = reader.ReadString() ?? ""; + return s.Length <= maxChars ? s : s.Substring(0, maxChars) + "..."; + } + catch + { + return ""; + } + } + + public RedisKey ReadRedisKey() => (RedisKey)reader.ReadByteArray(); + + public RedisChannel ReadRedisChannel(RedisChannel.RedisChannelOptions options) + => new(reader.ReadByteArray(), options); + + private bool TryGetFirst(out string first) + { + if (reader.IsNonNullAggregate && !reader.AggregateIsEmpty()) + { + var clone = reader.Clone(); + if (clone.TryMoveNext()) + { + unsafe + { + if (clone.IsScalar && + clone.TryParseScalar(&PhysicalConnection.PushKindMetadata.TryParse, out PhysicalConnection.PushKind kind)) + { + first = kind.ToString(); + return true; + } + } + + first = clone.GetOverview(); + return true; + } + } + first = ""; + return false; + } + + public string GetOverview() + { + // return reader.BufferUtf8(); // <== for when you really can't grok what is happening + if (reader.Prefix is RespPrefix.None) + { + var copy = reader; + copy.MovePastBof(); + return copy.Prefix is RespPrefix.None ? "(empty)" : copy.GetOverview(); + } + if (reader.IsNull) return "(null)"; + + return reader.Prefix switch + { + RespPrefix.SimpleString or RespPrefix.Integer or RespPrefix.SimpleError or RespPrefix.Double => $"{reader.Prefix}: {reader.ReadString()}", + RespPrefix.Push when reader.TryGetFirst(out var first) => $"{reader.Prefix} ({first}): {reader.AggregateLength()} items", + _ when reader.IsScalar => $"{reader.Prefix}: {reader.ScalarLength()} bytes, '{reader.DebugReadTruncatedString(16)}'", + _ when reader.IsAggregate => $"{reader.Prefix}: {reader.AggregateLength()} items", + _ => $"(unknown: {reader.Prefix})", + }; + } + + public RespPrefix GetFirstPrefix() + { + var prefix = reader.Prefix; + if (prefix is RespPrefix.None) + { + var mutable = reader; + mutable.MovePastBof(); + prefix = mutable.Prefix; + } + return prefix; + } + + /* + public bool AggregateHasAtLeast(int count) + { + reader.DemandAggregate(); + if (reader.IsNull) return false; + if (reader.IsStreaming) return CheckStreamingAggregateAtLeast(in reader, count); + return reader.AggregateLength() >= count; + + static bool CheckStreamingAggregateAtLeast(in RespReader reader, int count) + { + var iter = reader.AggregateChildren(); + object? attributes = null; + while (count > 0 && iter.MoveNextRaw(null!, ref attributes)) + { + count--; + } + + return count == 0; + } + } + */ + } + + extension(ref RespReader reader) + { + public bool SafeTryMoveNext() => reader.TryMoveNext(checkError: false) & !reader.IsError; + + public void MovePastBof() + { + // if we're at BOF, read the first element, ignoring errors + if (reader.Prefix is RespPrefix.None) reader.SafeTryMoveNext(); + } + + public RedisValue[]? ReadPastRedisValues() + => reader.ReadPastArray(static (ref r) => r.ReadRedisValue(), scalar: true); + + public seredis::StackExchange.Redis.Lease? AsLease() + { + if (!reader.IsScalar) throw new InvalidCastException("Cannot convert to Lease: " + reader.Prefix); + if (reader.IsNull) return null; + + var length = reader.ScalarLength(); + if (length == 0) return seredis::StackExchange.Redis.Lease.Empty; + + var lease = seredis::StackExchange.Redis.Lease.Create(length, clear: false); + if (reader.TryGetSpan(out var span)) + { + span.CopyTo(lease.Span); + } + else + { + var buffer = reader.Buffer(lease.Span); + Debug.Assert(buffer.Length == length, "buffer length mismatch"); + } + return lease; + } + } + + public static RespPrefix GetRespPrefix(ReadOnlySpan frame) + { + var reader = new RespReader(frame); + reader.SafeTryMoveNext(); + return reader.Prefix; + } + + extension(RespPrefix prefix) + { + public ResultType ToResultType() => prefix switch + { + RespPrefix.Array => ResultType.Array, + RespPrefix.Attribute => ResultType.Attribute, + RespPrefix.BigInteger => ResultType.BigInteger, + RespPrefix.Boolean => ResultType.Boolean, + RespPrefix.BulkError => ResultType.BlobError, + RespPrefix.BulkString => ResultType.BulkString, + RespPrefix.SimpleString => ResultType.SimpleString, + RespPrefix.Map => ResultType.Map, + RespPrefix.Set => ResultType.Set, + RespPrefix.Double => ResultType.Double, + RespPrefix.Integer => ResultType.Integer, + RespPrefix.SimpleError => ResultType.Error, + RespPrefix.Null => ResultType.Null, + RespPrefix.VerbatimString => ResultType.VerbatimString, + RespPrefix.Push=> ResultType.Push, + _ => throw new ArgumentOutOfRangeException(nameof(prefix), prefix, null), + }; + } + + extension(T?[] array) where T : class + { + internal bool AnyNull() + { + foreach (var el in array) + { + if (el is null) return true; + } + + return false; + } + } + +#if !(NET || NETSTANDARD2_1_OR_GREATER) + extension(Task task) + { + public bool IsCompletedSuccessfully => task.Status is TaskStatus.RanToCompletion; + } +#endif +} diff --git a/toys/StackExchange.Redis.Server/RespServer.cs b/toys/StackExchange.Redis.Server/RespServer.cs index fd592e3d8..7b22a1361 100644 --- a/toys/StackExchange.Redis.Server/RespServer.cs +++ b/toys/StackExchange.Redis.Server/RespServer.cs @@ -12,6 +12,9 @@ using System.Threading.Tasks; using Pipelines.Sockets.Unofficial; using Pipelines.Sockets.Unofficial.Arenas; +using RESPite; +using RESPite.Buffers; +using RESPite.Messages; namespace StackExchange.Redis.Server { @@ -41,7 +44,7 @@ public HashSet GetCommands() return set; } - private static Dictionary BuildCommands(RespServer server) + private static Dictionary BuildCommands(RespServer server) { static RedisCommandAttribute CheckSignatureAndGetAttribute(MethodInfo method) { @@ -51,13 +54,16 @@ static RedisCommandAttribute CheckSignatureAndGetAttribute(MethodInfo method) return null; return (RedisCommandAttribute)Attribute.GetCustomAttribute(method, typeof(RedisCommandAttribute)); } - var grouped = from method in server.GetType().GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) - let attrib = CheckSignatureAndGetAttribute(method) - where attrib != null - select new RespCommand(attrib, method, server) into cmd - group cmd by cmd.Command; - var result = new Dictionary(); + var grouped = ( + from method in server.GetType() + .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + let attrib = CheckSignatureAndGetAttribute(method) + where attrib != null + select new RespCommand(attrib, method, server)) + .GroupBy(x => new AsciiHash(x.Command.ToUpperInvariant()), AsciiHash.CaseSensitiveEqualityComparer); + + var result = new Dictionary(AsciiHash.CaseSensitiveEqualityComparer); foreach (var grp in grouped) { RespCommand parent; @@ -71,9 +77,8 @@ static RedisCommandAttribute CheckSignatureAndGetAttribute(MethodInfo method) parent = grp.Single(); } - var cmd = new CommandBytes(grp.Key); - Debug.WriteLine($"Registering: {cmd}"); - result.Add(cmd, parent); + Debug.WriteLine($"Registering: {grp.Key}"); + result.Add(grp.Key, parent); } return result; } @@ -110,22 +115,24 @@ public RedisCommandAttribute( public int Arity { get; } public bool LockFree { get; set; } } - private readonly Dictionary _commands; + private readonly Dictionary _commands; private readonly struct RespCommand { public RespCommand(RedisCommandAttribute attrib, MethodInfo method, RespServer server) { _operation = (RespOperation)Delegate.CreateDelegate(typeof(RespOperation), server, method); - Command = (string.IsNullOrWhiteSpace(attrib.Command) ? method.Name : attrib.Command).Trim().ToLowerInvariant(); - CommandBytes = new CommandBytes(Command); + + var command = attrib.Command; + if (string.IsNullOrEmpty(command)) command = method.Name; + + Command = command; SubCommand = attrib.SubCommand?.Trim()?.ToLowerInvariant(); Arity = attrib.Arity; MaxArgs = attrib.MaxArgs; LockFree = attrib.LockFree; _subcommands = null; } - private CommandBytes CommandBytes { get; } public string Command { get; } public string SubCommand { get; } public bool IsSubCommand => !string.IsNullOrEmpty(SubCommand); @@ -145,7 +152,6 @@ private RespCommand(in RespCommand parent, RespCommand[] subs) if (subs == null || subs.Length == 0) throw new InvalidOperationException("Cannot add empty sub-commands"); Command = parent.Command; - CommandBytes = parent.CommandBytes; SubCommand = parent.SubCommand; Arity = parent.Arity; MaxArgs = parent.MaxArgs; @@ -273,40 +279,60 @@ protected void DoShutdown(ShutdownReason reason) public void Dispose() => Dispose(true); protected virtual void Dispose(bool disposing) { - _arena.Dispose(); DoShutdown(ShutdownReason.ServerDisposed); } + private readonly Arena _arena = new(); + public virtual RedisServer.Node DefaultNode => null; public async Task RunClientAsync(IDuplexPipe pipe, RedisServer.Node node = null, object state = null) { Exception fault = null; RedisClient client = null; + byte[] commandLease = RedisRequest.GetLease(); + ReadOnlySequence buffer = default; + bool wasReading = false; try { node ??= DefaultNode; client = AddClient(node, state); - var incompleteOutput = client.WriteOutputAsync(pipe.Output); + Task output = client.WriteOutputAsync(pipe.Output); while (!client.Closed) { var readResult = await pipe.Input.ReadAsync().ConfigureAwait(false); - var buffer = readResult.Buffer; + buffer = readResult.Buffer; - bool makingProgress = false; - while (!client.Closed && await TryProcessRequestAsync(ref buffer, client).ConfigureAwait(false)) + wasReading = true; + while (!client.Closed && client.TryReadRequest(buffer, out long consumed)) { - makingProgress = true; - } - pipe.Input.AdvanceTo(buffer.Start, buffer.End); + wasReading = false; + // process a completed request + RedisRequest request = new(buffer.Slice(0, consumed), ref commandLease); + request = request.WithClient(client); + var response = Execute(client, request); - if (!makingProgress && readResult.IsCompleted) - { // nothing to do, and nothing more will be arriving - break; + if (client.ShouldSkipResponse() || response.IsNil) // elective or no-result + { + response.Recycle(); + } + else + { + await client.AddOutboundAsync(response); + } + client.ResetAfterRequest(); + + // advance the buffer to account for the message we just read + buffer = buffer.Slice(consumed); + wasReading = true; } + wasReading = false; + + pipe.Input.AdvanceTo(buffer.Start, buffer.End); + if (readResult.IsCompleted) break; // EOF } client.Complete(); - await incompleteOutput; + await output; } catch (ConnectionResetException) { } catch (ObjectDisposedException) { } @@ -321,6 +347,7 @@ public async Task RunClientAsync(IDuplexPipe pipe, RedisServer.Node node = null, } finally { + RedisRequest.ReleaseLease(ref commandLease); client?.Complete(fault); RemoveClient(client); try { pipe.Input.Complete(fault); } catch { } @@ -329,159 +356,49 @@ public async Task RunClientAsync(IDuplexPipe pipe, RedisServer.Node node = null, if (fault != null && !_isShutdown) { Log("Connection faulted (" + fault.GetType().Name + "): " + fault.Message); - } - } - } - public virtual void Log(string message) - { - var output = _output; - if (output != null) - { - lock (output) - { - output.WriteLine(message); + if (wasReading) + { + Log("Read fault, buffer: " + GetUtf8String(buffer)); + } } } } - public static async ValueTask WriteResponseAsync(RedisClient client, PipeWriter output, TypedRedisValue value, RedisProtocol protocol) + internal static string GetUtf8String(in ReadOnlySequence buffer) { - static void WritePrefix(PipeWriter output, char prefix) - { - var span = output.GetSpan(1); - span[0] = (byte)prefix; - output.Advance(1); - } - - if (value.IsNil) return; // not actually a request (i.e. empty/whitespace request) - if (client != null && client.ShouldSkipResponse()) return; // intentionally skipping the result - - var type = value.Type; - if (protocol is RedisProtocol.Resp2 & type is not ResultType.Null) - { - if (type is ResultType.VerbatimString) - { - var s = (string)value.AsRedisValue(); - if (s is { Length: >= 4 } && s[3] == ':') - value = TypedRedisValue.BulkString(s.Substring(4)); - } - type = type.ToResp2(); - } -RetryResp2: - if (protocol is RedisProtocol.Resp3 && value.IsNullValueOrArray) + if (buffer.IsEmpty) return "(empty)"; + char[] lease = null; + var maxLen = Encoding.UTF8.GetMaxCharCount(checked((int)buffer.Length)); + Span target = maxLen <= 128 ? stackalloc char[128] : (lease = ArrayPool.Shared.Rent(maxLen)); + int charCount = 0; + if (buffer.IsSingleSegment) { - output.Write("_\r\n"u8); + charCount = Encoding.UTF8.GetChars(buffer.First.Span, target); } else { - char prefix; - switch (type) + foreach (var segment in buffer) { - case ResultType.Integer: - PhysicalConnection.WriteInteger(output, (long)value.AsRedisValue()); - break; - case ResultType.Error: - prefix = '-'; - goto BasicMessage; - case ResultType.SimpleString: - prefix = '+'; - BasicMessage: - WritePrefix(output, prefix); - var val = (string)value.AsRedisValue(); - var expectedLength = Encoding.UTF8.GetByteCount(val); - PhysicalConnection.WriteRaw(output, val, expectedLength); - PhysicalConnection.WriteCrlf(output); - break; - case ResultType.BulkString: - PhysicalConnection.WriteBulkString(value.AsRedisValue(), output); - break; - case ResultType.Null: - case ResultType.Push when value.IsNullArray: - case ResultType.Map when value.IsNullArray: - case ResultType.Set when value.IsNullArray: - case ResultType.Attribute when value.IsNullArray: - output.Write("_\r\n"u8); - break; - case ResultType.Array when value.IsNullArray: - PhysicalConnection.WriteMultiBulkHeader(output, -1, type); - break; - case ResultType.Push: - case ResultType.Map: - case ResultType.Array: - case ResultType.Set: - case ResultType.Attribute: - var segment = value.Segment; - PhysicalConnection.WriteMultiBulkHeader(output, segment.Count, type); - var arr = segment.Array; - int offset = segment.Offset; - for (int i = 0; i < segment.Count; i++) - { - var item = arr[offset++]; - if (item.IsNil) - throw new InvalidOperationException("Array element cannot be nil, index " + i); - - // note: don't pass client down; this would impact SkipReplies - await WriteResponseAsync(null, output, item, protocol); - } - break; - default: - // retry with RESP2 - var r2 = type.ToResp2(); - if (r2 != type) - { - Debug.WriteLine($"{type} not handled in RESP3; using {r2} instead"); - goto RetryResp2; - } - - throw new InvalidOperationException( - "Unexpected result type: " + value.Type); + charCount += Encoding.UTF8.GetChars(segment.Span, target.Slice(charCount)); } } - - await output.FlushAsync().ConfigureAwait(false); - } - - private static bool TryParseRequest(Arena arena, ref ReadOnlySequence buffer, out RedisRequest request) - { - var reader = new BufferReader(buffer); - var raw = PhysicalConnection.TryParseResult(false, arena, in buffer, ref reader, false, null, true); - if (raw.HasValue) - { - buffer = reader.SliceFromCurrent(); - request = new RedisRequest(raw); - return true; - } - request = default; - - return false; + const string CR = "\u240D", LF = "\u240A", CRLF = CR + LF; + string s = target.Slice(0, charCount).ToString() + .Replace("\r\n", CRLF).Replace("\r", CR).Replace("\n", LF); + if (lease is not null) ArrayPool.Shared.Return(lease); + return s; } - private readonly Arena _arena = new Arena(); - - public ValueTask TryProcessRequestAsync(ref ReadOnlySequence buffer, RedisClient client) + public virtual void Log(string message) { - static async ValueTask Awaited(ValueTask write) - { - await write.ConfigureAwait(false); - return true; - } - if (!buffer.IsEmpty && TryParseRequest(_arena, ref buffer, out var request)) + var output = _output; + if (output != null) { - request = request.WithClient(client); - TypedRedisValue response; - try { response = Execute(client, request); } - finally + lock (output) { - _arena.Reset(); - client.ResetAfterRequest(); + output.WriteLine(message); } - - var write = client.AddOutboundAsync(response); - if (!write.IsCompletedSuccessfully) return Awaited(write); - write.GetAwaiter().GetResult(); - return new ValueTask(true); } - return new ValueTask(false); } protected object ServerSyncLock => this; @@ -502,20 +419,17 @@ public virtual TypedRedisValue OnUnknownCommand(in RedisClient client, in RedisR public virtual TypedRedisValue Execute(RedisClient client, in RedisRequest request) { - if (request.Count == 0) return default; // not a request - - if (!request.TryGetCommandBytes(0, out var cmdBytes)) + if (request.Count == 0 || request.Command.Length == 0) // not a request { client.ExecAbort(); return request.CommandNotFound(); } - if (cmdBytes.Length == 0) return default; // not a request Interlocked.Increment(ref _totalCommandsProcesed); try { TypedRedisValue result; - if (_commands.TryGetValue(cmdBytes, out var cmd)) + if (_commands.TryGetValue(request.Command, out var cmd)) { if (cmd.HasSubCommands) { @@ -527,7 +441,7 @@ public virtual TypedRedisValue Execute(RedisClient client, in RedisRequest reque } } - if (client.BufferMulti(request, cmdBytes)) return TypedRedisValue.SimpleString("QUEUED"); + if (client.BufferMulti(request, request.Command)) return TypedRedisValue.SimpleString("QUEUED"); if (cmd.LockFree) { @@ -544,18 +458,20 @@ public virtual TypedRedisValue Execute(RedisClient client, in RedisRequest reque else { client.ExecAbort(); - Span span = stackalloc byte[CommandBytes.MaxLength]; - cmdBytes.CopyTo(span); - result = OnUnknownCommand(client, request, span.Slice(0, cmdBytes.Length)); + result = OnUnknownCommand(client, request, request.Command.Span); } - if (result.Type == ResultType.Error) Interlocked.Increment(ref _totalErrorCount); + if (result.IsError) Interlocked.Increment(ref _totalErrorCount); return result; } - catch (KeyMovedException moved) when (GetNode(moved.HashSlot) is { } node) + catch (KeyMovedException moved) { - OnMoved(client, moved.HashSlot, node); - return TypedRedisValue.Error($"MOVED {moved.HashSlot} {node.Host}:{node.Port}"); + if (GetNode(moved.HashSlot) is { } node) + { + OnMoved(client, moved.HashSlot, node); + return TypedRedisValue.Error($"MOVED {moved.HashSlot} {node.Host}:{node.Port}"); + } + return TypedRedisValue.Error($"ERR key has been migrated from slot {moved.HashSlot}, but the new owner is unknown"); } catch (CrossSlotException) { @@ -607,42 +523,43 @@ public sealed class WrongTypeException : Exception protected internal static int GetHashSlot(in RedisKey key) => s_ClusterSelectionStrategy.HashSlot(key); private static readonly ServerSelectionStrategy s_ClusterSelectionStrategy = new(null) { ServerType = ServerType.Cluster }; + /* internal static string ToLower(in RawResult value) { var val = value.GetString(); if (string.IsNullOrWhiteSpace(val)) return val; return val.ToLowerInvariant(); } + */ [RedisCommand(1, LockFree = true)] protected virtual TypedRedisValue Command(RedisClient client, in RedisRequest request) { - var results = TypedRedisValue.Rent(_commands.Count, out var span, ResultType.Array); + var results = TypedRedisValue.Rent(_commands.Count, out var span, RespPrefix.Array); int index = 0; foreach (var pair in _commands) span[index++] = CommandInfo(pair.Value); return results; } - [RedisCommand(-2, "command", "info", LockFree = true)] + [RedisCommand(-2, nameof(RedisCommand.COMMAND), "info", LockFree = true)] protected virtual TypedRedisValue CommandInfo(RedisClient client, in RedisRequest request) { - var results = TypedRedisValue.Rent(request.Count - 2, out var span, ResultType.Array); + var results = TypedRedisValue.Rent(request.Count - 2, out var span, RespPrefix.Array); for (int i = 2; i < request.Count; i++) { - span[i - 2] = request.TryGetCommandBytes(i, out var cmdBytes) - && _commands.TryGetValue(cmdBytes, out var cmdInfo) - ? CommandInfo(cmdInfo) : TypedRedisValue.NullArray(ResultType.Array); + span[i - 2] = _commands.TryGetValue(request.Command, out var cmdInfo) + ? CommandInfo(cmdInfo) : TypedRedisValue.NullArray(RespPrefix.Array); } return results; } - private TypedRedisValue CommandInfo(RespCommand command) + private TypedRedisValue CommandInfo(in RespCommand command) { - var arr = TypedRedisValue.Rent(6, out var span, ResultType.Array); + var arr = TypedRedisValue.Rent(6, out var span, RespPrefix.Array); span[0] = TypedRedisValue.BulkString(command.Command); span[1] = TypedRedisValue.Integer(command.NetArity()); - span[2] = TypedRedisValue.EmptyArray(ResultType.Array); + span[2] = TypedRedisValue.EmptyArray(RespPrefix.Array); span[3] = TypedRedisValue.Zero; span[4] = TypedRedisValue.Zero; span[5] = TypedRedisValue.Zero; diff --git a/toys/StackExchange.Redis.Server/StackExchange.Redis.Server.csproj b/toys/StackExchange.Redis.Server/StackExchange.Redis.Server.csproj index 9908e9088..661b01e14 100644 --- a/toys/StackExchange.Redis.Server/StackExchange.Redis.Server.csproj +++ b/toys/StackExchange.Redis.Server/StackExchange.Redis.Server.csproj @@ -1,7 +1,7 @@  - netstandard2.0 + netstandard2.0;net10.0 Basic redis server based on StackExchange.Redis StackExchange.Redis StackExchange.Redis.Server @@ -12,7 +12,9 @@ $(NoWarn);CS1591 - + + + diff --git a/toys/StackExchange.Redis.Server/TypedRedisValue.cs b/toys/StackExchange.Redis.Server/TypedRedisValue.cs index d7fa7e4b6..0493399ac 100644 --- a/toys/StackExchange.Redis.Server/TypedRedisValue.cs +++ b/toys/StackExchange.Redis.Server/TypedRedisValue.cs @@ -1,6 +1,7 @@ using System; using System.Buffers; using System.Collections.Generic; +using RESPite.Messages; namespace StackExchange.Redis { @@ -11,14 +12,15 @@ public readonly struct TypedRedisValue { // note: if this ever becomes exposed on the public API, it should be made so that it clears; // can't trust external callers to clear the space, and using recycle without that is dangerous - internal static TypedRedisValue Rent(int count, out Span span, ResultType type) + internal static TypedRedisValue Rent(int count, out Span span, RespPrefix type) { if (count == 0) { span = default; return EmptyArray(type); } - var arr = ArrayPool.Shared.Rent(count); + + var arr = ArrayPool.Shared.Rent(count); // new TypedRedisValue[count]; span = new Span(arr, 0, count); return new TypedRedisValue(arr, count, type); } @@ -31,28 +33,28 @@ internal static TypedRedisValue Rent(int count, out Span span, /// /// Returns whether this value is an invalid empty value. /// - public bool IsNil => Type == ResultType.None; + public bool IsNil => Type == RespPrefix.None; /// /// Returns whether this value represents a null array. /// - public bool IsNullArray => IsAggregate && _value.DirectObject == null; + public bool IsNullArray => IsAggregate && _value.IsNull; private readonly RedisValue _value; /// /// The type of value being represented. /// - public ResultType Type { get; } + public RespPrefix Type { get; } /// /// Initialize a TypedRedisValue from a value and optionally a type. /// /// The value to initialize. /// The type of . - private TypedRedisValue(RedisValue value, ResultType? type = null) + private TypedRedisValue(RedisValue value, RespPrefix? type = null) { - Type = type ?? (value.IsInteger ? ResultType.Integer : ResultType.BulkString); + Type = type ?? (value.IsInteger ? RespPrefix.Integer : RespPrefix.BulkString); _value = value; } @@ -61,23 +63,24 @@ private TypedRedisValue(RedisValue value, ResultType? type = null) /// /// The error message. public static TypedRedisValue Error(string value) - => new TypedRedisValue(value, ResultType.Error); + => new TypedRedisValue(value, RespPrefix.SimpleError); /// /// Initialize a TypedRedisValue that represents a simple string. /// /// The string value. public static TypedRedisValue SimpleString(string value) - => new TypedRedisValue(value, ResultType.SimpleString); + => new TypedRedisValue(value, RespPrefix.SimpleString); /// /// The simple string OK. /// public static TypedRedisValue OK { get; } = SimpleString("OK"); + internal static TypedRedisValue Zero { get; } = Integer(0); internal static TypedRedisValue One { get; } = Integer(1); - internal static TypedRedisValue NullArray(ResultType type) => new TypedRedisValue((TypedRedisValue[])null, 0, type); - internal static TypedRedisValue EmptyArray(ResultType type) => new TypedRedisValue([], 0, type); + internal static TypedRedisValue NullArray(RespPrefix type) => new TypedRedisValue((TypedRedisValue[])null, 0, type); + internal static TypedRedisValue EmptyArray(RespPrefix type) => new TypedRedisValue([], 0, type); /// /// Gets the array elements as a span. @@ -86,40 +89,32 @@ public ReadOnlySpan Span { get { - if (!IsAggregate) return default; - var arr = (TypedRedisValue[])_value.DirectObject; - if (arr == null) return default; - var length = (int)_value.DirectOverlappedBits64; - return new ReadOnlySpan(arr, 0, length); - } - } - public ArraySegment Segment - { - get - { - if (!IsAggregate) return default; - var arr = (TypedRedisValue[])_value.DirectObject; - if (arr == null) return default; - var length = (int)_value.DirectOverlappedBits64; - return new ArraySegment(arr, 0, length); + if (_value.TryGetForeign(out var arr, out int index, out var length)) + { + return arr.AsSpan(index, length); + } + + return default; } } - public bool IsAggregate => Type.ToResp2() is ResultType.Array; + public bool IsAggregate => Type is RespPrefix.Array or RespPrefix.Set or RespPrefix.Map or RespPrefix.Push or RespPrefix.Attribute; + public bool IsNullValueOrArray => IsAggregate ? IsNullArray : _value.IsNull; + public bool IsError => Type is RespPrefix.SimpleError or RespPrefix.BulkError; /// /// Initialize a that represents an integer. /// /// The value to initialize from. public static TypedRedisValue Integer(long value) - => new TypedRedisValue(value, ResultType.Integer); + => new TypedRedisValue(value, RespPrefix.Integer); /// /// Initialize a from a . /// /// The items to intialize a value from. - public static TypedRedisValue MultiBulk(ReadOnlySpan items, ResultType type) + public static TypedRedisValue MultiBulk(ReadOnlySpan items, RespPrefix type) { if (items.IsEmpty) return EmptyArray(type); var result = Rent(items.Length, out var span, type); @@ -131,14 +126,19 @@ public static TypedRedisValue MultiBulk(ReadOnlySpan items, Res /// Initialize a from a collection. /// /// The items to intialize a value from. - public static TypedRedisValue MultiBulk(ICollection items, ResultType type) + public static TypedRedisValue MultiBulk(ICollection items, RespPrefix type) { if (items == null) return NullArray(type); int count = items.Count; if (count == 0) return EmptyArray(type); - var arr = ArrayPool.Shared.Rent(count); - items.CopyTo(arr, 0); - return new TypedRedisValue(arr, count, type); + var result = Rent(count, out var span, type); + int i = 0; + foreach (var item in items) + { + span[i++] = item; + } + + return result; } /// @@ -146,40 +146,44 @@ public static TypedRedisValue MultiBulk(ICollection items, Resu /// /// The value to initialize from. public static TypedRedisValue BulkString(RedisValue value) - => new TypedRedisValue(value, ResultType.BulkString); + => new TypedRedisValue(value, RespPrefix.BulkString); /// /// Initialize a that represents a bulk string. /// /// The value to initialize from. public static TypedRedisValue BulkString(in RedisChannel value) - => new TypedRedisValue((byte[])value, ResultType.BulkString); + => new TypedRedisValue((byte[])value, RespPrefix.BulkString); - private TypedRedisValue(TypedRedisValue[] oversizedItems, int count, ResultType type) + private TypedRedisValue(TypedRedisValue[] oversizedItems, int count, RespPrefix type) { if (oversizedItems == null) { if (count != 0) throw new ArgumentOutOfRangeException(nameof(count)); + oversizedItems = []; } else { if (count < 0 || count > oversizedItems.Length) throw new ArgumentOutOfRangeException(nameof(count)); - if (count == 0) oversizedItems = Array.Empty(); + if (count == 0) oversizedItems = []; } - _value = new RedisValue(oversizedItems, count); + + _value = RedisValue.CreateForeign(oversizedItems, 0, count); Type = type; } internal void Recycle(int limit = -1) { - if (_value.DirectObject is TypedRedisValue[] arr) + if (_value.TryGetForeign(out var arr, out var index, out var length)) { - if (limit < 0) limit = (int)_value.DirectOverlappedBits64; - for (int i = 0; i < limit; i++) + if (limit < 0) limit = length; + var span = arr.AsSpan(index, limit); + foreach (ref readonly TypedRedisValue el in span) { - arr[i].Recycle(); + el.Recycle(); } - ArrayPool.Shared.Return(arr, clearArray: false); + span.Clear(); + ArrayPool.Shared.Return(arr, clearArray: false); // we did it ourselves } } @@ -193,12 +197,14 @@ internal void Recycle(int limit = -1) /// public override string ToString() { + if (IsAggregate) return $"{Type}:[{Span.Length}]"; + switch (Type) { - case ResultType.BulkString: - case ResultType.SimpleString: - case ResultType.Integer: - case ResultType.Error: + case RespPrefix.BulkString: + case RespPrefix.SimpleString: + case RespPrefix.Integer: + case RespPrefix.SimpleError: return $"{Type}:{_value}"; default: return IsAggregate ? $"{Type}:[{Span.Length}]" : Type.ToString(); From f08d0ed982bca6e1da4a66e0f954143094e3a072 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 10 Mar 2026 11:51:20 +0000 Subject: [PATCH 02/24] - backport LCS and RedisType.VectorSet changes from v3 --- .../APITypes/LCSMatchResult.cs | 121 ++++++++++++++++-- src/StackExchange.Redis/Enums/RedisType.cs | 6 + .../PublicAPI/PublicAPI.Unshipped.txt | 16 +++ src/StackExchange.Redis/ResultProcessor.cs | 37 ++++-- .../StackExchange.Redis.Tests/StringTests.cs | 8 +- 5 files changed, 168 insertions(+), 20 deletions(-) diff --git a/src/StackExchange.Redis/APITypes/LCSMatchResult.cs b/src/StackExchange.Redis/APITypes/LCSMatchResult.cs index fdeea89ff..3aca6357b 100644 --- a/src/StackExchange.Redis/APITypes/LCSMatchResult.cs +++ b/src/StackExchange.Redis/APITypes/LCSMatchResult.cs @@ -1,11 +1,14 @@ using System; +using System.ComponentModel; +// ReSharper disable once CheckNamespace namespace StackExchange.Redis; /// /// The result of a LongestCommonSubsequence command with IDX feature. /// Returns a list of the positions of each sub-match. /// +// ReSharper disable once InconsistentNaming public readonly struct LCSMatchResult { internal static LCSMatchResult Null { get; } = new LCSMatchResult(Array.Empty(), 0); @@ -36,20 +39,92 @@ internal LCSMatchResult(LCSMatch[] matches, long matchLength) LongestMatchLength = matchLength; } + /// + /// Represents a position range in a string. + /// + // ReSharper disable once InconsistentNaming + public readonly struct LCSPosition : IEquatable + { + /// + /// The start index of the position. + /// + public long Start { get; } + + /// + /// The end index of the position. + /// + public long End { get; } + + /// + /// Returns a new Position. + /// + /// The start index. + /// The end index. + public LCSPosition(long start, long end) + { + Start = start; + End = end; + } + + /// + public override string ToString() => $"[{Start}..{End}]"; + + /// + public override int GetHashCode() + { + unchecked + { + return ((int)Start * 31) + (int)End; + } + } + + /// + public override bool Equals(object? obj) => obj is LCSPosition other && Equals(in other); + + /// + /// Compares this position to another for equality. + /// + [CLSCompliant(false)] + public bool Equals(in LCSPosition other) => Start == other.Start && End == other.End; + + /// + /// Compares this position to another for equality. + /// + bool IEquatable.Equals(LCSPosition other) => Equals(in other); + } + /// /// Represents a sub-match of the longest match. i.e first indexes the matched substring in each string. /// - public readonly struct LCSMatch + // ReSharper disable once InconsistentNaming + public readonly struct LCSMatch : IEquatable { + private readonly LCSPosition _first; + private readonly LCSPosition _second; + + /// + /// The position of the matched substring in the first string. + /// + public LCSPosition First => _first; + + /// + /// The position of the matched substring in the second string. + /// + public LCSPosition Second => _second; + /// /// The first index of the matched substring in the first string. /// - public long FirstStringIndex { get; } + [EditorBrowsable(EditorBrowsableState.Never)] + [Browsable(false)] + public long FirstStringIndex => _first.Start; /// /// The first index of the matched substring in the second string. /// - public long SecondStringIndex { get; } + [EditorBrowsable(EditorBrowsableState.Never)] + [Browsable(false)] + public long SecondStringIndex => _second.Start; /// /// The length of the match. @@ -59,14 +134,44 @@ public readonly struct LCSMatch /// /// Returns a new Match. /// - /// The first index of the matched substring in the first string. - /// The first index of the matched substring in the second string. + /// The position of the matched substring in the first string. + /// The position of the matched substring in the second string. /// The length of the match. - internal LCSMatch(long firstStringIndex, long secondStringIndex, long length) + internal LCSMatch(in LCSPosition first, in LCSPosition second, long length) { - FirstStringIndex = firstStringIndex; - SecondStringIndex = secondStringIndex; + _first = first; + _second = second; Length = length; } + + /// + public override string ToString() => $"First: {_first}, Second: {_second}, Length: {Length}"; + + /// + public override int GetHashCode() + { + unchecked + { + int hash = 17; + hash = (hash * 31) + _first.GetHashCode(); + hash = (hash * 31) + _second.GetHashCode(); + hash = (hash * 31) + Length.GetHashCode(); + return hash; + } + } + + /// + public override bool Equals(object? obj) => obj is LCSMatch other && Equals(in other); + + /// + /// Compares this match to another for equality. + /// + [CLSCompliant(false)] + public bool Equals(in LCSMatch other) => _first.Equals(in other._first) && _second.Equals(in other._second) && Length == other.Length; + + /// + /// Compares this match to another for equality. + /// + bool IEquatable.Equals(LCSMatch other) => Equals(in other); } } diff --git a/src/StackExchange.Redis/Enums/RedisType.cs b/src/StackExchange.Redis/Enums/RedisType.cs index f1da87505..90a41165b 100644 --- a/src/StackExchange.Redis/Enums/RedisType.cs +++ b/src/StackExchange.Redis/Enums/RedisType.cs @@ -65,5 +65,11 @@ public enum RedisType /// The data-type was not recognised by the client library. /// Unknown, + + /// + /// Vector sets are a data type similar to sorted sets, but instead of a score, + /// vector set elements have a string representation of a vector. + /// + VectorSet, } } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index ab058de62..797632e41 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,17 @@ #nullable enable +override StackExchange.Redis.LCSMatchResult.LCSMatch.Equals(object? obj) -> bool +override StackExchange.Redis.LCSMatchResult.LCSMatch.GetHashCode() -> int +override StackExchange.Redis.LCSMatchResult.LCSMatch.ToString() -> string! +override StackExchange.Redis.LCSMatchResult.LCSPosition.Equals(object? obj) -> bool +override StackExchange.Redis.LCSMatchResult.LCSPosition.GetHashCode() -> int +override StackExchange.Redis.LCSMatchResult.LCSPosition.ToString() -> string! +StackExchange.Redis.LCSMatchResult.LCSMatch.Equals(in StackExchange.Redis.LCSMatchResult.LCSMatch other) -> bool +StackExchange.Redis.LCSMatchResult.LCSMatch.First.get -> StackExchange.Redis.LCSMatchResult.LCSPosition +StackExchange.Redis.LCSMatchResult.LCSMatch.Second.get -> StackExchange.Redis.LCSMatchResult.LCSPosition +StackExchange.Redis.LCSMatchResult.LCSPosition +StackExchange.Redis.LCSMatchResult.LCSPosition.End.get -> long +StackExchange.Redis.LCSMatchResult.LCSPosition.Equals(in StackExchange.Redis.LCSMatchResult.LCSPosition other) -> bool +StackExchange.Redis.LCSMatchResult.LCSPosition.LCSPosition() -> void +StackExchange.Redis.LCSMatchResult.LCSPosition.LCSPosition(long start, long end) -> void +StackExchange.Redis.LCSMatchResult.LCSPosition.Start.get -> long +StackExchange.Redis.RedisType.VectorSet = 8 -> StackExchange.Redis.RedisType diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index 777bd0571..fc5c3d5b4 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -1898,14 +1898,14 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes { switch (result.Resp2TypeArray) { - case ResultType.Array: - SetResult(message, Parse(result)); + case ResultType.Array when TryParse(result, out var value): + SetResult(message, value); return true; } return false; } - private static LCSMatchResult Parse(in RawResult result) + private static bool TryParse(in RawResult result, out LCSMatchResult value) { var topItems = result.GetItems(); var matches = new LCSMatchResult.LCSMatch[topItems[1].GetItems().Length]; @@ -1915,14 +1915,35 @@ private static LCSMatchResult Parse(in RawResult result) { var matchItems = match.GetItems(); - matches[i++] = new LCSMatchResult.LCSMatch( - firstStringIndex: (long)matchItems[0].GetItems()[0].AsRedisValue(), - secondStringIndex: (long)matchItems[1].GetItems()[0].AsRedisValue(), - length: (long)matchItems[2].AsRedisValue()); + if (TryReadPosition(matchItems[0], out var first) + && TryReadPosition(matchItems[1], out var second) + && matchItems[2].TryGetInt64(out var length)) + { + matches[i++] = new LCSMatchResult.LCSMatch(first, second, length); + } + else + { + value = default; + return false; + } } var len = (long)topItems[3].AsRedisValue(); - return new LCSMatchResult(matches, len); + value = new LCSMatchResult(matches, len); + return true; + } + + private static bool TryReadPosition(in RawResult raw, out LCSMatchResult.LCSPosition position) + { + // Expecting a 2-element array: [start, end] + if (raw.Resp2TypeArray is ResultType.Array && raw.ItemsCount >= 2 + && raw[0].TryGetInt64(out var start) && raw[1].TryGetInt64(out var end)) + { + position = new LCSMatchResult.LCSPosition(start, end); + return true; + } + position = default; + return false; } } diff --git a/tests/StackExchange.Redis.Tests/StringTests.cs b/tests/StackExchange.Redis.Tests/StringTests.cs index 1ade532d7..2dcf8f6fb 100644 --- a/tests/StackExchange.Redis.Tests/StringTests.cs +++ b/tests/StackExchange.Redis.Tests/StringTests.cs @@ -967,8 +967,8 @@ public async Task LongestCommonSubsequence() var stringMatchResult = db.StringLongestCommonSubsequenceWithMatches(key1, key2); Assert.Equal(2, stringMatchResult.Matches.Length); // "my" and "text" are the two matches of the result - Assert.Equivalent(new LCSMatchResult.LCSMatch(4, 5, length: 4), stringMatchResult.Matches[0]); // the string "text" starts at index 4 in the first string and at index 5 in the second string - Assert.Equivalent(new LCSMatchResult.LCSMatch(2, 0, length: 2), stringMatchResult.Matches[1]); // the string "my" starts at index 2 in the first string and at index 0 in the second string + Assert.Equivalent(new LCSMatchResult.LCSMatch(new(4, 7), new(5, 8), length: 4), stringMatchResult.Matches[0]); // the string "text" starts at index 4 in the first string and at index 5 in the second string + Assert.Equivalent(new LCSMatchResult.LCSMatch(new(2, 3), new(0, 1), length: 2), stringMatchResult.Matches[1]); // the string "my" starts at index 2 in the first string and at index 0 in the second string stringMatchResult = db.StringLongestCommonSubsequenceWithMatches(key1, key2, 5); Assert.Empty(stringMatchResult.Matches); // no matches longer than 5 characters @@ -1007,8 +1007,8 @@ public async Task LongestCommonSubsequenceAsync() var stringMatchResult = await db.StringLongestCommonSubsequenceWithMatchesAsync(key1, key2); Assert.Equal(2, stringMatchResult.Matches.Length); // "my" and "text" are the two matches of the result - Assert.Equivalent(new LCSMatchResult.LCSMatch(4, 5, length: 4), stringMatchResult.Matches[0]); // the string "text" starts at index 4 in the first string and at index 5 in the second string - Assert.Equivalent(new LCSMatchResult.LCSMatch(2, 0, length: 2), stringMatchResult.Matches[1]); // the string "my" starts at index 2 in the first string and at index 0 in the second string + Assert.Equivalent(new LCSMatchResult.LCSMatch(new(4, 7), new(5, 8), length: 4), stringMatchResult.Matches[0]); // the string "text" starts at index 4 in the first string and at index 5 in the second string + Assert.Equivalent(new LCSMatchResult.LCSMatch(new(2, 3), new(0, 1), length: 2), stringMatchResult.Matches[1]); // the string "my" starts at index 2 in the first string and at index 0 in the second string stringMatchResult = await db.StringLongestCommonSubsequenceWithMatchesAsync(key1, key2, 5); Assert.Empty(stringMatchResult.Matches); // no matches longer than 5 characters From cba9baec6d0ea63e3fa436a696e37c300472e5c8 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 10 Mar 2026 12:42:21 +0000 Subject: [PATCH 03/24] CI --- .github/workflows/CI.yml | 248 ++++++++++++++++++----------------- .github/workflows/codeql.yml | 68 +++++----- StackExchange.Redis.sln | 1 + 3 files changed, 164 insertions(+), 153 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index a8ab53c74..3e49997e9 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -17,33 +17,35 @@ jobs: DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: "1" # Enable color output, even though the console output is redirected in Actions TERM: xterm # Enable color output in GitHub Actions steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Fetch the full history - - name: Start Redis Services (docker-compose) - working-directory: ./tests/RedisConfigs - run: docker compose -f docker-compose.yml up -d --wait - - name: Install .NET SDK - uses: actions/setup-dotnet@v3 - with: - dotnet-version: | - 6.0.x - 8.0.x - 10.0.x - - name: .NET Build - run: dotnet build Build.csproj -c Release /p:CI=true - - name: StackExchange.Redis.Tests - run: dotnet test tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj -c Release --logger trx --logger GitHubActions --results-directory ./test-results/ /p:CI=true - - uses: dorny/test-reporter@v1 - continue-on-error: true - if: success() || failure() - with: - name: Test Results - Ubuntu - path: 'test-results/*.trx' - reporter: dotnet-trx - - name: .NET Lib Pack - run: dotnet pack src/StackExchange.Redis/StackExchange.Redis.csproj --no-build -c Release /p:Packing=true /p:PackageOutputPath=%CD%\.nupkgs /p:CI=true + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch the full history + - name: Start Redis Services (docker-compose) + working-directory: ./tests/RedisConfigs + run: docker compose -f docker-compose.yml up -d --wait + - name: Install .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 6.0.x + 8.0.x + 10.0.x + - name: .NET Build (eng prebuild) + run: dotnet build eng/StackExchange.Redis.Build/StackExchange.Redis.Build.csproj -c Release /p:CI=true + - name: .NET Build + run: dotnet build Build.csproj -c Release /p:CI=true + - name: StackExchange.Redis.Tests + run: dotnet test tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj -c Release --logger trx --logger GitHubActions --results-directory ./test-results/ /p:CI=true + - uses: dorny/test-reporter@v1 + continue-on-error: true + if: success() || failure() + with: + name: Test Results - Ubuntu + path: 'test-results/*.trx' + reporter: dotnet-trx + - name: .NET Lib Pack + run: dotnet pack src/StackExchange.Redis/StackExchange.Redis.csproj --no-build -c Release /p:Packing=true /p:PackageOutputPath=%CD%\.nupkgs /p:CI=true windows: name: StackExchange.Redis (Windows Server 2022) @@ -54,99 +56,101 @@ jobs: TERM: xterm DOCKER_BUILDKIT: 1 steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Fetch the full history - - uses: Vampire/setup-wsl@v2 - with: - distribution: Ubuntu-22.04 - - name: Install Redis - shell: wsl-bash {0} - working-directory: ./tests/RedisConfigs - run: | - apt-get update - apt-get install curl gpg lsb-release libgomp1 jq -y - curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg - chmod 644 /usr/share/keyrings/redis-archive-keyring.gpg - echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | tee /etc/apt/sources.list.d/redis.list - apt-get update - apt-get install -y redis - mkdir redis - - name: Run redis-server - shell: wsl-bash {0} - working-directory: ./tests/RedisConfigs/redis - run: | - pwd - ls . - # Run each server instance in order - redis-server ../Basic/primary-6379.conf & - redis-server ../Basic/replica-6380.conf & - redis-server ../Basic/secure-6381.conf & - redis-server ../Failover/primary-6382.conf & - redis-server ../Failover/replica-6383.conf & - redis-server ../Cluster/cluster-7000.conf --dir ../Cluster & - redis-server ../Cluster/cluster-7001.conf --dir ../Cluster & - redis-server ../Cluster/cluster-7002.conf --dir ../Cluster & - redis-server ../Cluster/cluster-7003.conf --dir ../Cluster & - redis-server ../Cluster/cluster-7004.conf --dir ../Cluster & - redis-server ../Cluster/cluster-7005.conf --dir ../Cluster & - redis-server ../Sentinel/redis-7010.conf & - redis-server ../Sentinel/redis-7011.conf & - redis-server ../Sentinel/sentinel-26379.conf --sentinel & - redis-server ../Sentinel/sentinel-26380.conf --sentinel & - redis-server ../Sentinel/sentinel-26381.conf --sentinel & - # Wait for server instances to get ready - sleep 5 - echo "Checking redis-server version with port 6379" - redis-cli -p 6379 INFO SERVER | grep redis_version || echo "Failed to get version for port 6379" - echo "Checking redis-server version with port 6380" - redis-cli -p 6380 INFO SERVER | grep redis_version || echo "Failed to get version for port 6380" - echo "Checking redis-server version with port 6381" - redis-cli -p 6381 INFO SERVER | grep redis_version || echo "Failed to get version for port 6381" - echo "Checking redis-server version with port 6382" - redis-cli -p 6382 INFO SERVER | grep redis_version || echo "Failed to get version for port 6382" - echo "Checking redis-server version with port 6383" - redis-cli -p 6383 INFO SERVER | grep redis_version || echo "Failed to get version for port 6383" - echo "Checking redis-server version with port 7000" - redis-cli -p 7000 INFO SERVER | grep redis_version || echo "Failed to get version for port 7000" - echo "Checking redis-server version with port 7001" - redis-cli -p 7001 INFO SERVER | grep redis_version || echo "Failed to get version for port 7001" - echo "Checking redis-server version with port 7002" - redis-cli -p 7002 INFO SERVER | grep redis_version || echo "Failed to get version for port 7002" - echo "Checking redis-server version with port 7003" - redis-cli -p 7003 INFO SERVER | grep redis_version || echo "Failed to get version for port 7003" - echo "Checking redis-server version with port 7004" - redis-cli -p 7004 INFO SERVER | grep redis_version || echo "Failed to get version for port 7004" - echo "Checking redis-server version with port 7005" - redis-cli -p 7005 INFO SERVER | grep redis_version || echo "Failed to get version for port 7005" - echo "Checking redis-server version with port 7010" - redis-cli -p 7010 INFO SERVER | grep redis_version || echo "Failed to get version for port 7010" - echo "Checking redis-server version with port 7011" - redis-cli -p 7011 INFO SERVER | grep redis_version || echo "Failed to get version for port 7011" - echo "Checking redis-server version with port 26379" - redis-cli -p 26379 INFO SERVER | grep redis_version || echo "Failed to get version for port 26379" - echo "Checking redis-server version with port 26380" - redis-cli -p 26380 INFO SERVER | grep redis_version || echo "Failed to get version for port 26380" - echo "Checking redis-server version with port 26381" - redis-cli -p 26381 INFO SERVER | grep redis_version || echo "Failed to get version for port 26381" - continue-on-error: true + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch the full history + - uses: Vampire/setup-wsl@v2 + with: + distribution: Ubuntu-22.04 + - name: Install Redis + shell: wsl-bash {0} + working-directory: ./tests/RedisConfigs + run: | + apt-get update + apt-get install curl gpg lsb-release libgomp1 jq -y + curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg + chmod 644 /usr/share/keyrings/redis-archive-keyring.gpg + echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | tee /etc/apt/sources.list.d/redis.list + apt-get update + apt-get install -y redis + mkdir redis + - name: Run redis-server + shell: wsl-bash {0} + working-directory: ./tests/RedisConfigs/redis + run: | + pwd + ls . + # Run each server instance in order + redis-server ../Basic/primary-6379.conf & + redis-server ../Basic/replica-6380.conf & + redis-server ../Basic/secure-6381.conf & + redis-server ../Failover/primary-6382.conf & + redis-server ../Failover/replica-6383.conf & + redis-server ../Cluster/cluster-7000.conf --dir ../Cluster & + redis-server ../Cluster/cluster-7001.conf --dir ../Cluster & + redis-server ../Cluster/cluster-7002.conf --dir ../Cluster & + redis-server ../Cluster/cluster-7003.conf --dir ../Cluster & + redis-server ../Cluster/cluster-7004.conf --dir ../Cluster & + redis-server ../Cluster/cluster-7005.conf --dir ../Cluster & + redis-server ../Sentinel/redis-7010.conf & + redis-server ../Sentinel/redis-7011.conf & + redis-server ../Sentinel/sentinel-26379.conf --sentinel & + redis-server ../Sentinel/sentinel-26380.conf --sentinel & + redis-server ../Sentinel/sentinel-26381.conf --sentinel & + # Wait for server instances to get ready + sleep 5 + echo "Checking redis-server version with port 6379" + redis-cli -p 6379 INFO SERVER | grep redis_version || echo "Failed to get version for port 6379" + echo "Checking redis-server version with port 6380" + redis-cli -p 6380 INFO SERVER | grep redis_version || echo "Failed to get version for port 6380" + echo "Checking redis-server version with port 6381" + redis-cli -p 6381 INFO SERVER | grep redis_version || echo "Failed to get version for port 6381" + echo "Checking redis-server version with port 6382" + redis-cli -p 6382 INFO SERVER | grep redis_version || echo "Failed to get version for port 6382" + echo "Checking redis-server version with port 6383" + redis-cli -p 6383 INFO SERVER | grep redis_version || echo "Failed to get version for port 6383" + echo "Checking redis-server version with port 7000" + redis-cli -p 7000 INFO SERVER | grep redis_version || echo "Failed to get version for port 7000" + echo "Checking redis-server version with port 7001" + redis-cli -p 7001 INFO SERVER | grep redis_version || echo "Failed to get version for port 7001" + echo "Checking redis-server version with port 7002" + redis-cli -p 7002 INFO SERVER | grep redis_version || echo "Failed to get version for port 7002" + echo "Checking redis-server version with port 7003" + redis-cli -p 7003 INFO SERVER | grep redis_version || echo "Failed to get version for port 7003" + echo "Checking redis-server version with port 7004" + redis-cli -p 7004 INFO SERVER | grep redis_version || echo "Failed to get version for port 7004" + echo "Checking redis-server version with port 7005" + redis-cli -p 7005 INFO SERVER | grep redis_version || echo "Failed to get version for port 7005" + echo "Checking redis-server version with port 7010" + redis-cli -p 7010 INFO SERVER | grep redis_version || echo "Failed to get version for port 7010" + echo "Checking redis-server version with port 7011" + redis-cli -p 7011 INFO SERVER | grep redis_version || echo "Failed to get version for port 7011" + echo "Checking redis-server version with port 26379" + redis-cli -p 26379 INFO SERVER | grep redis_version || echo "Failed to get version for port 26379" + echo "Checking redis-server version with port 26380" + redis-cli -p 26380 INFO SERVER | grep redis_version || echo "Failed to get version for port 26380" + echo "Checking redis-server version with port 26381" + redis-cli -p 26381 INFO SERVER | grep redis_version || echo "Failed to get version for port 26381" + continue-on-error: true - - name: .NET Build - run: dotnet build Build.csproj -c Release /p:CI=true - - name: StackExchange.Redis.Tests - run: dotnet test tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj -c Release --logger trx --logger GitHubActions --results-directory ./test-results/ /p:CI=true - - uses: dorny/test-reporter@v1 - continue-on-error: true - if: success() || failure() - with: - name: Tests Results - Windows Server 2022 - path: 'test-results/*.trx' - reporter: dotnet-trx - # Package and upload to MyGet only on pushes to main, not on PRs - - name: .NET Pack - if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' - run: dotnet pack Build.csproj --no-build -c Release /p:PackageOutputPath=${env:GITHUB_WORKSPACE}\.nupkgs /p:CI=true - - name: Upload to MyGet - if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' - run: dotnet nuget push ${env:GITHUB_WORKSPACE}\.nupkgs\*.nupkg -s https://www.myget.org/F/stackoverflow/api/v2/package -k ${{ secrets.MYGET_API_KEY }} + - name: .NET Build (eng prebuild) + run: dotnet build eng/StackExchange.Redis.Build/StackExchange.Redis.Build.csproj -c Release /p:CI=true + - name: .NET Build + run: dotnet build Build.csproj -c Release /p:CI=true + - name: StackExchange.Redis.Tests + run: dotnet test tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj -c Release --logger trx --logger GitHubActions --results-directory ./test-results/ /p:CI=true + - uses: dorny/test-reporter@v1 + continue-on-error: true + if: success() || failure() + with: + name: Tests Results - Windows Server 2022 + path: 'test-results/*.trx' + reporter: dotnet-trx + # Package and upload to MyGet only on pushes to main, not on PRs + - name: .NET Pack + if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' + run: dotnet pack Build.csproj --no-build -c Release /p:PackageOutputPath=${env:GITHUB_WORKSPACE}\.nupkgs /p:CI=true + - name: Upload to MyGet + if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' + run: dotnet nuget push ${env:GITHUB_WORKSPACE}\.nupkgs\*.nupkg -s https://www.myget.org/F/stackoverflow/api/v2/package -k ${{ secrets.MYGET_API_KEY }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index fa4a444bf..31c25dc13 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -7,7 +7,7 @@ on: # The branches below must be a subset of the branches above branches: [ 'main' ] workflow_dispatch: - + schedule: - cron: '8 9 * * 1' @@ -30,33 +30,39 @@ jobs: # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '9.0.x' - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v4 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - - if: matrix.language == 'csharp' - name: .NET Build - run: dotnet build Build.csproj -c Release /p:CI=true - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 - with: - category: "/language:${{matrix.language}}" + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.0.x + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + - if: matrix.language == 'csharp' + name: .NET Build (eng prebuild) + run: dotnet build eng/StackExchange.Redis.Build/StackExchange.Redis.Build.csproj -c Release /p:CI=true + + - if: matrix.language == 'csharp' + name: .NET Build + run: dotnet build Build.csproj -c Release /p:CI=true + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/StackExchange.Redis.sln b/StackExchange.Redis.sln index c5614da7c..4d275ad4f 100644 --- a/StackExchange.Redis.sln +++ b/StackExchange.Redis.sln @@ -21,6 +21,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Shared.ruleset = Shared.ruleset version.json = version.json tests\RedisConfigs\.docker\Redis\Dockerfile = tests\RedisConfigs\.docker\Redis\Dockerfile + .github\workflows\codeql.yml = .github\workflows\codeql.yml EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "RedisConfigs", "RedisConfigs", "{96E891CD-2ED7-4293-A7AB-4C6F5D8D2B05}" From bcc0b55f9b32f1f0d6f0ce775b3f7c85a9477a38 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 10 Mar 2026 13:59:27 +0000 Subject: [PATCH 04/24] tidy TFMs --- .github/workflows/codeql.yml | 1 - Build.csproj | 1 + .../StackExchange.Redis.csproj | 11 +- src/StackExchange.Redis/TestHarness.cs | 278 ++++++++++++++++++ 4 files changed, 285 insertions(+), 6 deletions(-) create mode 100644 src/StackExchange.Redis/TestHarness.cs diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 31c25dc13..b3fc43f95 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -39,7 +39,6 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: | - 8.0.x 10.0.x # Initializes the CodeQL tools for scanning. diff --git a/Build.csproj b/Build.csproj index 3e16e801c..41fb15b0c 100644 --- a/Build.csproj +++ b/Build.csproj @@ -1,5 +1,6 @@ + diff --git a/src/StackExchange.Redis/StackExchange.Redis.csproj b/src/StackExchange.Redis/StackExchange.Redis.csproj index 20b772bc2..1975dda6a 100644 --- a/src/StackExchange.Redis/StackExchange.Redis.csproj +++ b/src/StackExchange.Redis/StackExchange.Redis.csproj @@ -2,7 +2,7 @@ enable - net461;netstandard2.0;net472;netcoreapp3.1;net6.0;net8.0 + net461;netstandard2.0;net472;net6.0;net8.0;net10.0 High performance Redis client, incorporating both synchronous and asynchronous usage. StackExchange.Redis StackExchange.Redis @@ -41,10 +41,11 @@ - - - - + + + + + diff --git a/src/StackExchange.Redis/TestHarness.cs b/src/StackExchange.Redis/TestHarness.cs new file mode 100644 index 000000000..5b4395ffd --- /dev/null +++ b/src/StackExchange.Redis/TestHarness.cs @@ -0,0 +1,278 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using RESPite; +using RESPite.Messages; + +namespace StackExchange.Redis; + +/// +/// Allows unit testing RESP formatting and parsing. +/// +[Experimental(Experiments.UnitTesting, UrlFormat = Experiments.UrlFormat)] +public class TestHarness(CommandMap? commandMap = null, RedisChannel channelPrefix = default, RedisKey keyPrefix = default) +{ + /// + /// Channel prefix to use when writing values. + /// + public RedisChannel ChannelPrefix { get; } = channelPrefix; + + /// + /// Channel prefix to use when writing values. + /// + public RedisKey KeyPrefix => _keyPrefix; + private readonly byte[]? _keyPrefix = keyPrefix; + + /// + /// The command map to use when writing root commands. + /// + public CommandMap CommandMap { get; } = commandMap ?? CommandMap.Default; + + /// + /// Write a RESP frame from a command and set of arguments. + /// + public byte[] Write(string command, params ICollection args) + { + var msg = new RedisDatabase.ExecuteMessage(CommandMap, -1, CommandFlags.None, command, Fixup(args)); + var writer = new MessageWriter(ChannelPrefix, CommandMap, MessageWriter.BlockBuffer); + ReadOnlyMemory payload = default; + try + { + msg.WriteTo(writer); + payload = MessageWriter.FlushBlockBuffer(); + return payload.Span.ToArray(); + } + catch + { + MessageWriter.RevertBlockBuffer(); + throw; + } + finally + { + MessageWriter.ReleaseBlockBuffer(payload); + } + } + + /// + /// Write a RESP frame from a command and set of arguments. + /// + public void Write(IBufferWriter target, string command, params ICollection args) + { + // if we're using someone else's buffer writer, then we don't need to worry about our local + // memory-management rules + if (target is null) throw new ArgumentNullException(nameof(target)); + var msg = new RedisDatabase.ExecuteMessage(CommandMap, -1, CommandFlags.None, command, Fixup(args)); + var writer = new MessageWriter(ChannelPrefix, CommandMap, target); + msg.WriteTo(writer); + } + + /// + /// Report a validation failure. + /// + protected virtual void OnValidateFail(in RedisKey expected, in RedisKey actual) + => throw new InvalidOperationException($"Routing key is not equal: '{expected}' vs '{actual}' (hint: override {nameof(OnValidateFail)})"); + + /// + /// Report a validation failure. + /// + protected virtual void OnValidateFail(string expected, string actual) + => throw new InvalidOperationException($"RESP is not equal: '{expected}' vs '{actual}' (hint: override {nameof(OnValidateFail)})"); + + /// + /// Report a validation failure. + /// + protected virtual void OnValidateFail(ReadOnlyMemory expected, ReadOnlyMemory actual) + => OnValidateFail(Encoding.UTF8.GetString(expected.Span), Encoding.UTF8.GetString(actual.Span)); + + /// + /// Write a RESP frame from a command and set of arguments, and allow a callback to validate + /// the RESP content. + /// + public void ValidateResp(ReadOnlySpan expected, string command, params ICollection args) + { + var msg = new RedisDatabase.ExecuteMessage(CommandMap, -1, CommandFlags.None, command, Fixup(args)); + var writer = new MessageWriter(ChannelPrefix, CommandMap, MessageWriter.BlockBuffer); + ReadOnlyMemory actual = default; + byte[]? lease = null; + try + { + msg.WriteTo(writer); + actual = MessageWriter.FlushBlockBuffer(); + if (!expected.SequenceEqual(actual.Span)) + { + lease = ArrayPool.Shared.Rent(expected.Length); + expected.CopyTo(lease); + OnValidateFail(lease.AsMemory(0, expected.Length), lease); + } + } + catch + { + MessageWriter.RevertBlockBuffer(); + throw; + } + finally + { + if (lease is not null) ArrayPool.Shared.Return(lease); + MessageWriter.ReleaseBlockBuffer(actual); + } + } + + private ICollection Fixup(ICollection? args) + { + if (_keyPrefix is { Length: > 0 } && args is { } && args.Any(x => x is RedisKey)) + { + object[] copy = new object[args.Count]; + int i = 0; + foreach (object value in args) + { + if (value is RedisKey key) + { + copy[i++] = RedisKey.WithPrefix(_keyPrefix, key); + } + else + { + copy[i++] = value; + } + } + + return copy; + } + + return args ?? []; + } + + /// + /// Write a RESP frame from a command and set of arguments, and allow a callback to validate + /// the RESP content. + /// + public void ValidateResp(string expected, string command, params ICollection args) + { + var msg = new RedisDatabase.ExecuteMessage(CommandMap, 0, CommandFlags.None, command, Fixup(args)); + var writer = new MessageWriter(ChannelPrefix, CommandMap, MessageWriter.BlockBuffer); + ReadOnlyMemory payload = default; + char[]? lease = null; + try + { + msg.WriteTo(writer); + payload = MessageWriter.FlushBlockBuffer(); + lease = ArrayPool.Shared.Rent(Encoding.UTF8.GetMaxCharCount(payload.Length)); + var chars = Encoding.UTF8.GetChars(payload.Span, lease.AsSpan()); + var actual = lease.AsSpan(0, chars); + if (!actual.SequenceEqual(expected)) + { + OnValidateFail(expected, actual.ToString()); + } + } + catch + { + MessageWriter.RevertBlockBuffer(); + throw; + } + finally + { + if (lease is not null) ArrayPool.Shared.Return(lease); + MessageWriter.ReleaseBlockBuffer(payload); + } + } + + /// + /// A callback with a payload buffer. + /// + public delegate void BufferValidator(scoped ReadOnlySpan buffer); + + /// + /// Deserialize a RESP frame as a . + /// + public RedisResult Read(ReadOnlySpan value) + { + var reader = new RespReader(value); + if (!RedisResult.TryCreate(null, ref reader, out var result)) + { + throw new ArgumentException(nameof(value)); + } + return result; + } + + /// + /// Convenience handler for comparing span fragments, typically used with "Assert.Equal" or similar + /// as the handler. + /// + public static void AssertEqual( + ReadOnlySpan expected, + ReadOnlySpan actual, + Action, ReadOnlyMemory> handler) + { + if (!expected.SequenceEqual(actual)) Fault(expected, actual, handler); + static void Fault( + ReadOnlySpan expected, + ReadOnlySpan actual, + Action, ReadOnlyMemory> handler) + { + var lease = ArrayPool.Shared.Rent(expected.Length + actual.Length); + try + { + var leaseMemory = lease.AsMemory(); + var x = leaseMemory.Slice(0, expected.Length); + var y = leaseMemory.Slice(expected.Length, actual.Length); + expected.CopyTo(x.Span); + actual.CopyTo(y.Span); + handler(x, y); + } + finally + { + ArrayPool.Shared.Return(lease); + } + } + } + + /// + /// Convenience handler for comparing span fragments, typically used with "Assert.Equal" or similar + /// as the handler. + /// + public static void AssertEqual( + string expected, + ReadOnlySpan actual, + Action handler) + { + var lease = ArrayPool.Shared.Rent(Encoding.UTF8.GetMaxByteCount(expected.Length)); + try + { + var bytes = Encoding.UTF8.GetBytes(expected.AsSpan(), lease.AsSpan()); + var span = lease.AsSpan(0, bytes); + if (!span.SequenceEqual(actual)) handler(expected, Encoding.UTF8.GetString(span)); + } + finally + { + ArrayPool.Shared.Return(lease); + } + } + + /// + /// Verify that the routing of a command matches the intent. + /// + public void ValidateRouting(in RedisKey expected, params ICollection args) + { + var expectedWithPrefix = RedisKey.WithPrefix(_keyPrefix, expected); + var actual = ServerSelectionStrategy.NoSlot; + + RedisKey last = RedisKey.Null; + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (args is not null) + { + foreach (var arg in args) + { + if (arg is RedisKey key) + { + last = RedisKey.WithPrefix(_keyPrefix, key); + var slot = ServerSelectionStrategy.GetHashSlot(last); + actual = ServerSelectionStrategy.CombineSlot(actual, slot); + } + } + } + + if (ServerSelectionStrategy.GetHashSlot(expectedWithPrefix) != actual) OnValidateFail(expectedWithPrefix, last); + } +} From c54d94d53910b69f639bab3a8017d8674283d9d9 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 10 Mar 2026 14:03:02 +0000 Subject: [PATCH 05/24] nix TestHarness --- src/StackExchange.Redis/TestHarness.cs | 278 ------------------------- 1 file changed, 278 deletions(-) delete mode 100644 src/StackExchange.Redis/TestHarness.cs diff --git a/src/StackExchange.Redis/TestHarness.cs b/src/StackExchange.Redis/TestHarness.cs deleted file mode 100644 index 5b4395ffd..000000000 --- a/src/StackExchange.Redis/TestHarness.cs +++ /dev/null @@ -1,278 +0,0 @@ -using System; -using System.Buffers; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text; -using RESPite; -using RESPite.Messages; - -namespace StackExchange.Redis; - -/// -/// Allows unit testing RESP formatting and parsing. -/// -[Experimental(Experiments.UnitTesting, UrlFormat = Experiments.UrlFormat)] -public class TestHarness(CommandMap? commandMap = null, RedisChannel channelPrefix = default, RedisKey keyPrefix = default) -{ - /// - /// Channel prefix to use when writing values. - /// - public RedisChannel ChannelPrefix { get; } = channelPrefix; - - /// - /// Channel prefix to use when writing values. - /// - public RedisKey KeyPrefix => _keyPrefix; - private readonly byte[]? _keyPrefix = keyPrefix; - - /// - /// The command map to use when writing root commands. - /// - public CommandMap CommandMap { get; } = commandMap ?? CommandMap.Default; - - /// - /// Write a RESP frame from a command and set of arguments. - /// - public byte[] Write(string command, params ICollection args) - { - var msg = new RedisDatabase.ExecuteMessage(CommandMap, -1, CommandFlags.None, command, Fixup(args)); - var writer = new MessageWriter(ChannelPrefix, CommandMap, MessageWriter.BlockBuffer); - ReadOnlyMemory payload = default; - try - { - msg.WriteTo(writer); - payload = MessageWriter.FlushBlockBuffer(); - return payload.Span.ToArray(); - } - catch - { - MessageWriter.RevertBlockBuffer(); - throw; - } - finally - { - MessageWriter.ReleaseBlockBuffer(payload); - } - } - - /// - /// Write a RESP frame from a command and set of arguments. - /// - public void Write(IBufferWriter target, string command, params ICollection args) - { - // if we're using someone else's buffer writer, then we don't need to worry about our local - // memory-management rules - if (target is null) throw new ArgumentNullException(nameof(target)); - var msg = new RedisDatabase.ExecuteMessage(CommandMap, -1, CommandFlags.None, command, Fixup(args)); - var writer = new MessageWriter(ChannelPrefix, CommandMap, target); - msg.WriteTo(writer); - } - - /// - /// Report a validation failure. - /// - protected virtual void OnValidateFail(in RedisKey expected, in RedisKey actual) - => throw new InvalidOperationException($"Routing key is not equal: '{expected}' vs '{actual}' (hint: override {nameof(OnValidateFail)})"); - - /// - /// Report a validation failure. - /// - protected virtual void OnValidateFail(string expected, string actual) - => throw new InvalidOperationException($"RESP is not equal: '{expected}' vs '{actual}' (hint: override {nameof(OnValidateFail)})"); - - /// - /// Report a validation failure. - /// - protected virtual void OnValidateFail(ReadOnlyMemory expected, ReadOnlyMemory actual) - => OnValidateFail(Encoding.UTF8.GetString(expected.Span), Encoding.UTF8.GetString(actual.Span)); - - /// - /// Write a RESP frame from a command and set of arguments, and allow a callback to validate - /// the RESP content. - /// - public void ValidateResp(ReadOnlySpan expected, string command, params ICollection args) - { - var msg = new RedisDatabase.ExecuteMessage(CommandMap, -1, CommandFlags.None, command, Fixup(args)); - var writer = new MessageWriter(ChannelPrefix, CommandMap, MessageWriter.BlockBuffer); - ReadOnlyMemory actual = default; - byte[]? lease = null; - try - { - msg.WriteTo(writer); - actual = MessageWriter.FlushBlockBuffer(); - if (!expected.SequenceEqual(actual.Span)) - { - lease = ArrayPool.Shared.Rent(expected.Length); - expected.CopyTo(lease); - OnValidateFail(lease.AsMemory(0, expected.Length), lease); - } - } - catch - { - MessageWriter.RevertBlockBuffer(); - throw; - } - finally - { - if (lease is not null) ArrayPool.Shared.Return(lease); - MessageWriter.ReleaseBlockBuffer(actual); - } - } - - private ICollection Fixup(ICollection? args) - { - if (_keyPrefix is { Length: > 0 } && args is { } && args.Any(x => x is RedisKey)) - { - object[] copy = new object[args.Count]; - int i = 0; - foreach (object value in args) - { - if (value is RedisKey key) - { - copy[i++] = RedisKey.WithPrefix(_keyPrefix, key); - } - else - { - copy[i++] = value; - } - } - - return copy; - } - - return args ?? []; - } - - /// - /// Write a RESP frame from a command and set of arguments, and allow a callback to validate - /// the RESP content. - /// - public void ValidateResp(string expected, string command, params ICollection args) - { - var msg = new RedisDatabase.ExecuteMessage(CommandMap, 0, CommandFlags.None, command, Fixup(args)); - var writer = new MessageWriter(ChannelPrefix, CommandMap, MessageWriter.BlockBuffer); - ReadOnlyMemory payload = default; - char[]? lease = null; - try - { - msg.WriteTo(writer); - payload = MessageWriter.FlushBlockBuffer(); - lease = ArrayPool.Shared.Rent(Encoding.UTF8.GetMaxCharCount(payload.Length)); - var chars = Encoding.UTF8.GetChars(payload.Span, lease.AsSpan()); - var actual = lease.AsSpan(0, chars); - if (!actual.SequenceEqual(expected)) - { - OnValidateFail(expected, actual.ToString()); - } - } - catch - { - MessageWriter.RevertBlockBuffer(); - throw; - } - finally - { - if (lease is not null) ArrayPool.Shared.Return(lease); - MessageWriter.ReleaseBlockBuffer(payload); - } - } - - /// - /// A callback with a payload buffer. - /// - public delegate void BufferValidator(scoped ReadOnlySpan buffer); - - /// - /// Deserialize a RESP frame as a . - /// - public RedisResult Read(ReadOnlySpan value) - { - var reader = new RespReader(value); - if (!RedisResult.TryCreate(null, ref reader, out var result)) - { - throw new ArgumentException(nameof(value)); - } - return result; - } - - /// - /// Convenience handler for comparing span fragments, typically used with "Assert.Equal" or similar - /// as the handler. - /// - public static void AssertEqual( - ReadOnlySpan expected, - ReadOnlySpan actual, - Action, ReadOnlyMemory> handler) - { - if (!expected.SequenceEqual(actual)) Fault(expected, actual, handler); - static void Fault( - ReadOnlySpan expected, - ReadOnlySpan actual, - Action, ReadOnlyMemory> handler) - { - var lease = ArrayPool.Shared.Rent(expected.Length + actual.Length); - try - { - var leaseMemory = lease.AsMemory(); - var x = leaseMemory.Slice(0, expected.Length); - var y = leaseMemory.Slice(expected.Length, actual.Length); - expected.CopyTo(x.Span); - actual.CopyTo(y.Span); - handler(x, y); - } - finally - { - ArrayPool.Shared.Return(lease); - } - } - } - - /// - /// Convenience handler for comparing span fragments, typically used with "Assert.Equal" or similar - /// as the handler. - /// - public static void AssertEqual( - string expected, - ReadOnlySpan actual, - Action handler) - { - var lease = ArrayPool.Shared.Rent(Encoding.UTF8.GetMaxByteCount(expected.Length)); - try - { - var bytes = Encoding.UTF8.GetBytes(expected.AsSpan(), lease.AsSpan()); - var span = lease.AsSpan(0, bytes); - if (!span.SequenceEqual(actual)) handler(expected, Encoding.UTF8.GetString(span)); - } - finally - { - ArrayPool.Shared.Return(lease); - } - } - - /// - /// Verify that the routing of a command matches the intent. - /// - public void ValidateRouting(in RedisKey expected, params ICollection args) - { - var expectedWithPrefix = RedisKey.WithPrefix(_keyPrefix, expected); - var actual = ServerSelectionStrategy.NoSlot; - - RedisKey last = RedisKey.Null; - // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract - if (args is not null) - { - foreach (var arg in args) - { - if (arg is RedisKey key) - { - last = RedisKey.WithPrefix(_keyPrefix, key); - var slot = ServerSelectionStrategy.GetHashSlot(last); - actual = ServerSelectionStrategy.CombineSlot(actual, slot); - } - } - } - - if (ServerSelectionStrategy.GetHashSlot(expectedWithPrefix) != actual) OnValidateFail(expectedWithPrefix, last); - } -} From d01051c363ab0cf19139dc65b78a32ff15a8f1c6 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 10 Mar 2026 14:06:10 +0000 Subject: [PATCH 06/24] deal with TFM-specific warnings from .NET 10 --- src/StackExchange.Redis/ConfigurationOptions.cs | 4 ++++ src/StackExchange.Redis/ExceptionFactory.cs | 2 +- src/StackExchange.Redis/PhysicalConnection.cs | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/StackExchange.Redis/ConfigurationOptions.cs b/src/StackExchange.Redis/ConfigurationOptions.cs index c0021f024..9136d2333 100644 --- a/src/StackExchange.Redis/ConfigurationOptions.cs +++ b/src/StackExchange.Redis/ConfigurationOptions.cs @@ -340,7 +340,9 @@ internal static LocalCertificateSelectionCallback CreatePemUserCertificateCallba internal static LocalCertificateSelectionCallback CreatePfxUserCertificateCallback(string userCertificatePath, string? password, X509KeyStorageFlags storageFlags = X509KeyStorageFlags.DefaultKeySet) { +#pragma warning disable SYSLIB0057 var pfx = new X509Certificate2(userCertificatePath, password ?? "", storageFlags); +#pragma warning restore SYSLIB0057 return (sender, targetHost, localCertificates, remoteCertificate, acceptableIssuers) => pfx; } @@ -351,7 +353,9 @@ internal static LocalCertificateSelectionCallback CreatePfxUserCertificateCallba public void TrustIssuer(X509Certificate2 issuer) => CertificateValidationCallback = TrustIssuerCallback(issuer); internal static RemoteCertificateValidationCallback TrustIssuerCallback(string issuerCertificatePath) +#pragma warning disable SYSLIB0057 => TrustIssuerCallback(new X509Certificate2(issuerCertificatePath)); +#pragma warning restore SYSLIB0057 private static RemoteCertificateValidationCallback TrustIssuerCallback(X509Certificate2 issuer) { if (issuer == null) throw new ArgumentNullException(nameof(issuer)); diff --git a/src/StackExchange.Redis/ExceptionFactory.cs b/src/StackExchange.Redis/ExceptionFactory.cs index 7e4eca49a..3cfb0268c 100644 --- a/src/StackExchange.Redis/ExceptionFactory.cs +++ b/src/StackExchange.Redis/ExceptionFactory.cs @@ -107,7 +107,7 @@ internal static Exception NoConnectionAvailable( serverSnapshot = new ServerEndPoint[] { server }; } - var innerException = PopulateInnerExceptions(serverSnapshot == default ? multiplexer.GetServerSnapshot() : serverSnapshot); + var innerException = PopulateInnerExceptions(serverSnapshot.IsEmpty ? multiplexer.GetServerSnapshot() : serverSnapshot); // Try to get a useful error message for the user. long attempts = multiplexer._connectAttemptCount, completions = multiplexer._connectCompletedCount; diff --git a/src/StackExchange.Redis/PhysicalConnection.cs b/src/StackExchange.Redis/PhysicalConnection.cs index 6320f709f..f35c6d81d 100644 --- a/src/StackExchange.Redis/PhysicalConnection.cs +++ b/src/StackExchange.Redis/PhysicalConnection.cs @@ -913,7 +913,7 @@ internal void WriteHeader(RedisCommand command, int arguments, CommandBytes comm internal void RecordQuit() { // don't blame redis if we fired the first shot - Thread.VolatileWrite(ref clientSentQuit, 1); + Volatile.Write(ref clientSentQuit, 1); (_ioPipe as SocketConnection)?.TrySetProtocolShutdown(PipeShutdownKind.ProtocolExitClient); } From 8378a066f07e71f84a56e652590bd9bd5f7b2a23 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 10 Mar 2026 15:03:10 +0000 Subject: [PATCH 07/24] in-proc test enhancements --- .../StackExchange.Redis.Tests/BasicOpTests.cs | 86 +++++------- .../StackExchange.Redis.Tests/DuplexStream.cs | 131 ++++++++++++++++++ .../Helpers/InProcServerFixture.cs | 5 +- .../InProcessTestServer.cs | 123 +++++++++++++--- .../StackExchange.Redis.Tests/PubSubTests.cs | 44 +++++- tests/StackExchange.Redis.Tests/TestBase.cs | 14 +- .../RedisClient.Output.cs | 46 +++--- toys/StackExchange.Redis.Server/RespServer.cs | 1 + 8 files changed, 349 insertions(+), 101 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/DuplexStream.cs diff --git a/tests/StackExchange.Redis.Tests/BasicOpTests.cs b/tests/StackExchange.Redis.Tests/BasicOpTests.cs index f7b0be324..094a5bdf9 100644 --- a/tests/StackExchange.Redis.Tests/BasicOpTests.cs +++ b/tests/StackExchange.Redis.Tests/BasicOpTests.cs @@ -16,6 +16,7 @@ public class BasicOpsTests(ITestOutputHelper output, SharedConnectionFixture fix public class InProcBasicOpsTests(ITestOutputHelper output, InProcServerFixture fixture) : BasicOpsTestsBase(output, null, fixture) { + protected override bool UseDedicatedInProcessServer => true; } [RunPerProtocol] @@ -25,7 +26,7 @@ public abstract class BasicOpsTestsBase(ITestOutputHelper output, SharedConnecti [Fact] public async Task PingOnce() { - await using var conn = Create(); + await using var conn = ConnectFactory(); var db = conn.GetDatabase(); var duration = await db.PingAsync().ForAwait(); @@ -33,17 +34,18 @@ public async Task PingOnce() Assert.True(duration.TotalMilliseconds > 0); } - [Fact(Skip = "This needs some CI love, it's not a scenario we care about too much but noisy atm.")] + [Fact] public async Task RapidDispose() { - await using var primary = Create(); + SkipIfWouldUseRealServer("This needs some CI love, it's not a scenario we care about too much but noisy atm."); + await using var primary = ConnectFactory(); var db = primary.GetDatabase(); RedisKey key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); for (int i = 0; i < 10; i++) { - await using var secondary = Create(fail: true, shared: false); + await using var secondary = primary.CreateClient(); secondary.GetDatabase().StringIncrement(key, flags: CommandFlags.FireAndForget); } // Give it a moment to get through the pipe...they were fire and forget @@ -54,7 +56,7 @@ public async Task RapidDispose() [Fact] public async Task PingMany() { - await using var conn = Create(); + await using var conn = ConnectFactory(); var db = conn.GetDatabase(); var tasks = new Task[100]; for (int i = 0; i < tasks.Length; i++) @@ -69,7 +71,7 @@ public async Task PingMany() [Fact] public async Task GetWithNullKey() { - await using var conn = Create(); + await using var conn = ConnectFactory(); var db = conn.GetDatabase(); const string? key = null; var ex = Assert.Throws(() => db.StringGet(key)); @@ -79,7 +81,7 @@ public async Task GetWithNullKey() [Fact] public async Task SetWithNullKey() { - await using var conn = Create(); + await using var conn = ConnectFactory(); var db = conn.GetDatabase(); const string? key = null, value = "abc"; var ex = Assert.Throws(() => db.StringSet(key!, value)); @@ -89,7 +91,7 @@ public async Task SetWithNullKey() [Fact] public async Task SetWithNullValue() { - await using var conn = Create(); + await using var conn = ConnectFactory(); var db = conn.GetDatabase(); string key = Me(); const string? value = null; @@ -107,7 +109,7 @@ public async Task SetWithNullValue() [Fact] public async Task SetWithDefaultValue() { - await using var conn = Create(); + await using var conn = ConnectFactory(); var db = conn.GetDatabase(); string key = Me(); var value = default(RedisValue); // this is kinda 0... ish @@ -125,7 +127,7 @@ public async Task SetWithDefaultValue() [Fact] public async Task SetWithZeroValue() { - await using var conn = Create(); + await using var conn = ConnectFactory(); var db = conn.GetDatabase(); string key = Me(); const long value = 0; @@ -143,7 +145,7 @@ public async Task SetWithZeroValue() [Fact] public async Task GetSetAsync() { - await using var conn = Create(); + await using var conn = ConnectFactory(); var db = conn.GetDatabase(); RedisKey key = Me(); @@ -168,7 +170,7 @@ public async Task GetSetAsync() [Fact] public async Task GetSetSync() { - await using var conn = Create(); + await using var conn = ConnectFactory(); var db = conn.GetDatabase(); RedisKey key = Me(); @@ -195,7 +197,7 @@ public async Task GetSetSync() [InlineData(true, false)] public async Task GetWithExpiry(bool exists, bool hasExpiry) { - await using var conn = Create(); + await using var conn = ConnectFactory(); var db = conn.GetDatabase(); RedisKey key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -231,7 +233,7 @@ public async Task GetWithExpiry(bool exists, bool hasExpiry) [Fact] public async Task GetWithExpiryWrongTypeAsync() { - await using var conn = Create(); + await using var conn = ConnectFactory(); var db = conn.GetDatabase(); RedisKey key = Me(); _ = db.KeyDeleteAsync(key); @@ -254,11 +256,11 @@ public async Task GetWithExpiryWrongTypeAsync() [Fact] public async Task GetWithExpiryWrongTypeSync() { + await using var conn = ConnectFactory(); + var db = conn.GetDatabase(); RedisKey key = Me(); var ex = await Assert.ThrowsAsync(async () => { - await using var conn = Create(); - var db = conn.GetDatabase(); db.KeyDelete(key, CommandFlags.FireAndForget); db.SetAdd(key, "abc", CommandFlags.FireAndForget); db.StringGetWithExpiry(key); @@ -270,13 +272,15 @@ public async Task GetWithExpiryWrongTypeSync() [Fact] public async Task TestSevered() { - SetExpectedAmbientFailureCount(2); - await using var conn = Create(allowAdmin: true, shared: false); + await using var conn = ConnectFactory(allowAdmin: true, shared: false); var db = conn.GetDatabase(); string key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); db.StringSet(key, key, flags: CommandFlags.FireAndForget); - var server = GetServer(conn); + var server = GetServer(conn.DefaultClient); + Assert.SkipUnless(server.CanSimulateConnectionFailure(), "Skipping because server cannot simulate connection failure"); + + SetExpectedAmbientFailureCount(2); server.SimulateConnectionFailure(SimulatedFailureType.All); var watch = Stopwatch.StartNew(); await UntilConditionAsync(TimeSpan.FromSeconds(10), () => server.IsConnected); @@ -291,7 +295,7 @@ public async Task TestSevered() [Fact] public async Task IncrAsync() { - await using var conn = Create(); + await using var conn = ConnectFactory(); var db = conn.GetDatabase(); RedisKey key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -319,7 +323,7 @@ public async Task IncrAsync() [Fact] public async Task IncrSync() { - await using var conn = Create(); + await using var conn = ConnectFactory(); var db = conn.GetDatabase(); RedisKey key = Me(); Log(key); @@ -348,7 +352,7 @@ public async Task IncrSync() [Fact] public async Task IncrDifferentSizes() { - await using var conn = Create(); + await using var conn = ConnectFactory(); var db = conn.GetDatabase(); RedisKey key = Me(); db.KeyDelete(key, CommandFlags.FireAndForget); @@ -378,30 +382,10 @@ private static void Incr(IDatabase database, RedisKey key, int delta, ref int to total += delta; } - [Fact] - public async Task ShouldUseSharedMuxer() - { - Log($"Shared: {SharedFixtureAvailable}"); - if (SharedFixtureAvailable) - { - await using var a = Create(); - Assert.IsNotType(a); - await using var b = Create(); - Assert.Same(a, b); - } - else - { - await using var a = Create(); - Assert.IsType(a); - await using var b = Create(); - Assert.NotSame(a, b); - } - } - [Fact] public async Task Delete() { - await using var conn = Create(); + await using var conn = ConnectFactory(); var db = conn.GetDatabase(); var key = Me(); _ = db.StringSetAsync(key, "Heyyyyy"); @@ -416,7 +400,7 @@ public async Task Delete() [Fact] public async Task DeleteAsync() { - await using var conn = Create(); + await using var conn = ConnectFactory(); var db = conn.GetDatabase(); var key = Me(); _ = db.StringSetAsync(key, "Heyyyyy"); @@ -431,7 +415,7 @@ public async Task DeleteAsync() [Fact] public async Task DeleteMany() { - await using var conn = Create(); + await using var conn = ConnectFactory(); var db = conn.GetDatabase(); var key1 = Me(); var key2 = Me() + "2"; @@ -450,7 +434,7 @@ public async Task DeleteMany() [Fact] public async Task DeleteManyAsync() { - await using var conn = Create(); + await using var conn = ConnectFactory(); var db = conn.GetDatabase(); var key1 = Me(); var key2 = Me() + "2"; @@ -470,7 +454,7 @@ public async Task DeleteManyAsync() public async Task WrappedDatabasePrefixIntegration() { var key = Me(); - await using var conn = Create(); + await using var conn = ConnectFactory(); var db = conn.GetDatabase().WithKeyPrefix("abc"); db.KeyDelete(key, CommandFlags.FireAndForget); db.StringIncrement(key, flags: CommandFlags.FireAndForget); @@ -484,8 +468,8 @@ public async Task WrappedDatabasePrefixIntegration() [Fact] public async Task TransactionSync() { - await using var conn = Create(); - Assert.SkipUnless(conn.RawConfig.CommandMap.IsAvailable(RedisCommand.MULTI), "MULTI is not available"); + await using var conn = ConnectFactory(); + Assert.SkipUnless(conn.DefaultClient.RawConfig.CommandMap.IsAvailable(RedisCommand.MULTI), "MULTI is not available"); var db = conn.GetDatabase(); RedisKey key = Me(); @@ -504,8 +488,8 @@ public async Task TransactionSync() [Fact] public async Task TransactionAsync() { - await using var conn = Create(); - Assert.SkipUnless(conn.RawConfig.CommandMap.IsAvailable(RedisCommand.MULTI), "MULTI is not available"); + await using var conn = ConnectFactory(); + Assert.SkipUnless(conn.DefaultClient.RawConfig.CommandMap.IsAvailable(RedisCommand.MULTI), "MULTI is not available"); var db = conn.GetDatabase(); diff --git a/tests/StackExchange.Redis.Tests/DuplexStream.cs b/tests/StackExchange.Redis.Tests/DuplexStream.cs new file mode 100644 index 000000000..fc3d23955 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/DuplexStream.cs @@ -0,0 +1,131 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace StackExchange.Redis.Tests; + +/// +/// Combines separate input and output streams into a single duplex stream. +/// +internal sealed class DuplexStream(Stream inputStream, Stream outputStream) : Stream +{ + private readonly Stream _inputStream = inputStream ?? throw new ArgumentNullException(nameof(inputStream)); + private readonly Stream _outputStream = outputStream ?? throw new ArgumentNullException(nameof(outputStream)); + + public override bool CanRead => _inputStream.CanRead; + public override bool CanWrite => _outputStream.CanWrite; + public override bool CanSeek => false; + public override bool CanTimeout => _inputStream.CanTimeout || _outputStream.CanTimeout; + + public override int ReadTimeout + { + get => _inputStream.ReadTimeout; + set => _inputStream.ReadTimeout = value; + } + + public override int WriteTimeout + { + get => _outputStream.WriteTimeout; + set => _outputStream.WriteTimeout = value; + } + + public override long Length => throw new NotSupportedException($"{nameof(DuplexStream)} does not support seeking."); + public override long Position + { + get => throw new NotSupportedException($"{nameof(DuplexStream)} does not support seeking."); + set => throw new NotSupportedException($"{nameof(DuplexStream)} does not support seeking."); + } + + public override int Read(byte[] buffer, int offset, int count) + => _inputStream.Read(buffer, offset, count); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => _inputStream.ReadAsync(buffer, offset, count, cancellationToken); + +#if NET6_0_OR_GREATER + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + => _inputStream.ReadAsync(buffer, cancellationToken); + + public override int Read(Span buffer) + => _inputStream.Read(buffer); +#endif + + public override int ReadByte() + => _inputStream.ReadByte(); + + public override void Write(byte[] buffer, int offset, int count) + => _outputStream.Write(buffer, offset, count); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => _outputStream.WriteAsync(buffer, offset, count, cancellationToken); + +#if NET6_0_OR_GREATER + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + => _outputStream.WriteAsync(buffer, cancellationToken); + + public override void Write(ReadOnlySpan buffer) + => _outputStream.Write(buffer); +#endif + + public override void WriteByte(byte value) + => _outputStream.WriteByte(value); + + public override void Flush() + => _outputStream.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) + => _outputStream.FlushAsync(cancellationToken); + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException($"{nameof(DuplexStream)} does not support seeking."); + + public override void SetLength(long value) + => throw new NotSupportedException($"{nameof(DuplexStream)} does not support seeking."); + + public override void Close() + { + _inputStream.Close(); + _outputStream.Close(); + base.Close(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inputStream.Dispose(); + _outputStream.Dispose(); + } + base.Dispose(disposing); + } + +#if NET6_0_OR_GREATER + public override async ValueTask DisposeAsync() + { + await _inputStream.DisposeAsync().ConfigureAwait(false); + await _outputStream.DisposeAsync().ConfigureAwait(false); + await base.DisposeAsync().ConfigureAwait(false); + } +#endif + + public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) + => _inputStream.BeginRead(buffer, offset, count, callback, state); + + public override int EndRead(IAsyncResult asyncResult) + => _inputStream.EndRead(asyncResult); + + public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) + => _outputStream.BeginWrite(buffer, offset, count, callback, state); + + public override void EndWrite(IAsyncResult asyncResult) + => _outputStream.EndWrite(asyncResult); + +#if NET6_0_OR_GREATER + public override void CopyTo(Stream destination, int bufferSize) + => _inputStream.CopyTo(destination, bufferSize); +#endif + + public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) + => _inputStream.CopyToAsync(destination, bufferSize, cancellationToken); +} diff --git a/tests/StackExchange.Redis.Tests/Helpers/InProcServerFixture.cs b/tests/StackExchange.Redis.Tests/Helpers/InProcServerFixture.cs index 5e801e5ca..9f5a5a59b 100644 --- a/tests/StackExchange.Redis.Tests/Helpers/InProcServerFixture.cs +++ b/tests/StackExchange.Redis.Tests/Helpers/InProcServerFixture.cs @@ -23,5 +23,8 @@ public InProcServerFixture() public Tunnel? Tunnel => _server.Tunnel; - public void Dispose() => _server.Dispose(); + public void Dispose() + { + try { _server.Dispose(); } catch { } + } } diff --git a/tests/StackExchange.Redis.Tests/InProcessTestServer.cs b/tests/StackExchange.Redis.Tests/InProcessTestServer.cs index 0e7d77ec2..988a657fd 100644 --- a/tests/StackExchange.Redis.Tests/InProcessTestServer.cs +++ b/tests/StackExchange.Redis.Tests/InProcessTestServer.cs @@ -1,4 +1,5 @@ -using System; +extern alias respite; +using System; using System.IO; using System.IO.Pipelines; using System.Net; @@ -6,7 +7,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; -using Pipelines.Sockets.Unofficial; +using respite::RESPite.Messages; using StackExchange.Redis.Configuration; using StackExchange.Redis.Server; using Xunit; @@ -25,19 +26,53 @@ public InProcessTestServer(ITestOutputHelper? log = null) Tunnel = new InProcTunnel(this); } - public Task ConnectAsync(TextWriter? log = null) - => ConnectionMultiplexer.ConnectAsync(GetClientConfig(), log); + public Task ConnectAsync(bool withPubSub = true /*, WriteMode writeMode = WriteMode.Default */, TextWriter? log = null) + => ConnectionMultiplexer.ConnectAsync(GetClientConfig(withPubSub /*, writeMode */), log); - public ConfigurationOptions GetClientConfig() + // view request/response highlights in the log + public override TypedRedisValue Execute(RedisClient client, in RedisRequest request) { - var commands = GetCommands(); + var result = base.Execute(client, in request); + var type = client.ApplyProtocol(result.Type); + if (result.IsNil) + { + Log($"[{client}] {request.Command} (no reply)"); + } + else if (result.IsAggregate) + { + Log($"[{client}] {request.Command} => {(char)type} ({type}, {result.Span.Length})"); + } + else + { + try + { + var s = result.AsRedisValue().ToString() ?? "(null)"; + const int MAX_CHARS = 16; + s = s.Length <= MAX_CHARS ? s : s.Substring(0, MAX_CHARS) + "..."; + Log($"[{client}] {request.Command} => {(char)type} ({type}) {s}"); + } + catch + { + Log($"[{client}] {request.Command} => {(char)type} ({type})"); + } + } + return result; + } - // transactions don't work yet (needs v3 buffer features) - commands.Remove(nameof(RedisCommand.MULTI)); - commands.Remove(nameof(RedisCommand.EXEC)); - commands.Remove(nameof(RedisCommand.DISCARD)); - commands.Remove(nameof(RedisCommand.WATCH)); - commands.Remove(nameof(RedisCommand.UNWATCH)); + public ConfigurationOptions GetClientConfig(bool withPubSub = true /*, WriteMode writeMode = WriteMode.Default */) + { + var commands = GetCommands(); + if (!withPubSub) + { + commands.Remove(nameof(RedisCommand.SUBSCRIBE)); + commands.Remove(nameof(RedisCommand.PSUBSCRIBE)); + commands.Remove(nameof(RedisCommand.SSUBSCRIBE)); + commands.Remove(nameof(RedisCommand.UNSUBSCRIBE)); + commands.Remove(nameof(RedisCommand.PUNSUBSCRIBE)); + commands.Remove(nameof(RedisCommand.SUNSUBSCRIBE)); + commands.Remove(nameof(RedisCommand.PUBLISH)); + commands.Remove(nameof(RedisCommand.SPUBLISH)); + } var config = new ConfigurationOptions { @@ -50,7 +85,25 @@ public ConfigurationOptions GetClientConfig() AsyncTimeout = 5000, AllowAdmin = true, Tunnel = Tunnel, + // WriteMode = (BufferedStreamWriter.WriteMode)writeMode, }; + if (!string.IsNullOrEmpty(Password)) config.Password = Password; + + /* useful for viewing *outbound* data in the log +#if DEBUG + if (_log is not null) + { + config.OutputLog = msg => + { + lock (_log) + { + _log.WriteLine(msg); + } + }; + } +#endif + */ + foreach (var endpoint in GetEndPoints()) { config.EndPoints.Add(endpoint); @@ -74,26 +127,51 @@ protected override void OnMoved(RedisClient client, int hashSlot, Node node) protected override void OnOutOfBand(RedisClient client, TypedRedisValue message) { + var type = client.ApplyProtocol(message.Type); if (message.IsAggregate && message.Span is { IsEmpty: false } span && !span[0].IsAggregate) { - _log?.WriteLine($"Client {client.Id}: {span[0].AsRedisValue()} {message} "); + _log?.WriteLine($"[{client}] => {(char)type} ({type}, {message.Span.Length}): {span[0].AsRedisValue()}"); } else { - _log?.WriteLine($"Client {client.Id}: {message}"); + _log?.WriteLine($"[{client}] => {(char)type} ({type})"); } base.OnOutOfBand(client, message); } + /* + public override void OnFlush(RedisClient client, int messages, long bytes) + { + if (bytes >= 0) + { + _log?.WriteLine($"[{client}] flushed {messages} messages, {bytes} bytes"); + } + else + { + _log?.WriteLine($"[{client}] flushed {messages} messages"); // bytes not available + } + base.OnFlush(client, messages, bytes); + } + */ + public override TypedRedisValue OnUnknownCommand(in RedisClient client, in RedisRequest request, ReadOnlySpan command) { - _log?.WriteLine($"[{client.Id}] unknown command: {Encoding.ASCII.GetString(command)}"); + _log?.WriteLine($"[{client}] unknown command: {Encoding.ASCII.GetString(command)}"); return base.OnUnknownCommand(in client, in request, command); } + public override void OnClientConnected(RedisClient client, object state) + { + if (state is TaskCompletionSource pending) + { + pending.TrySetResult(client); + } + base.OnClientConnected(client, state); + } + private sealed class InProcTunnel( InProcessTestServer server, PipeOptions? pipeOptions = null) : Tunnel @@ -118,13 +196,20 @@ private sealed class InProcTunnel( { if (server.TryGetNode(endpoint, out var node)) { - server._log?.WriteLine( - $"Client intercepted, endpoint {Format.ToString(endpoint)} ({connectionType}) mapped to {server.ServerType} node {node}"); var clientToServer = new Pipe(pipeOptions ?? PipeOptions.Default); var serverToClient = new Pipe(pipeOptions ?? PipeOptions.Default); var serverSide = new Duplex(clientToServer.Reader, serverToClient.Writer); - _ = Task.Run(async () => await server.RunClientAsync(serverSide, node: node), cancellationToken); - var clientSide = StreamConnection.GetDuplex(serverToClient.Reader, clientToServer.Writer); + + TaskCompletionSource 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 to {Format.ToString(endpoint)} ({connectionType} mapped to {server.ServerType} node {node})"); + + var readStream = serverToClient.Reader.AsStream(); + var writeStream = clientToServer.Writer.AsStream(); + var clientSide = new DuplexStream(readStream, writeStream); return new(clientSide); } return base.BeforeAuthenticateAsync(endpoint, connectionType, socket, cancellationToken); diff --git a/tests/StackExchange.Redis.Tests/PubSubTests.cs b/tests/StackExchange.Redis.Tests/PubSubTests.cs index 7e3db5292..65e1ee574 100644 --- a/tests/StackExchange.Redis.Tests/PubSubTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubTests.cs @@ -16,14 +16,12 @@ public class PubSubTests(ITestOutputHelper output, SharedConnectionFixture fixtu { } -/* [RunPerProtocol] public class InProcPubSubTests(ITestOutputHelper output, InProcServerFixture fixture) : PubSubTestBase(output, null, fixture) { - protected override bool UseDedicatedInProcessServer => false; + protected override bool UseDedicatedInProcessServer => true; } -*/ [RunPerProtocol] public abstract class PubSubTestBase( @@ -155,6 +153,18 @@ public async Task TestBasicPubSub(string? channelPrefix, bool wildCard, string b Assert.Equal(0, count); } + [Fact] + public async Task Ping() + { + await using var conn = ConnectFactory(shared: false); + var pub = GetAnyPrimary(conn.DefaultClient); + var sub = conn.GetSubscriber(); + + await PingAsync(pub, sub, 5).ForAwait(); + await sub.SubscribeAsync(RedisChannel.Literal(Me()), (_, __) => { }); // to ensure we're in subscriber mode + await PingAsync(pub, sub, 5).ForAwait(); + } + [Fact] public async Task TestBasicPubSubFireAndForget() { @@ -223,10 +233,28 @@ private async Task PingAsync(IServer pub, ISubscriber sub, int times = 1) // way to prove that is to use TPL objects var subTask = sub.PingAsync(); var pubTask = pub.PingAsync(); - await Task.WhenAll(subTask, pubTask).ForAwait(); + try + { + await Task.WhenAll(subTask, pubTask).ForAwait(); + } + catch (TimeoutException ex) + { + throw new TimeoutException($"Timeout; sub: {GetState(subTask)}, pub: {GetState(pubTask)}", ex); + } - Log($"Sub PING time: {subTask.Result.TotalMilliseconds} ms"); - Log($"Pub PING time: {pubTask.Result.TotalMilliseconds} ms"); + Log($"sub: {GetState(subTask)}, pub: {GetState(pubTask)}"); + + static string GetState(Task pending) + { + var status = pending.Status; + return status switch + { + TaskStatus.RanToCompletion => $"{status} in {pending.Result.TotalMilliseconds:###,##0.0}ms)", + TaskStatus.Faulted when pending.Exception is { InnerExceptions.Count:1 } ae => $"{status}: {ae.InnerExceptions[0].Message}", + TaskStatus.Faulted => $"{status}: {pending.Exception?.Message}", + _ => status.ToString(), + }; + } } } @@ -314,6 +342,7 @@ public async Task TestMassivePublishWithWithoutFlush_Local() public async Task TestMassivePublishWithWithoutFlush_Remote() { Skip.UnlessLongRunning(); + SkipIfWouldUseInProcessServer(); await using var conn = Create(configuration: TestConfig.Current.RemoteServerAndPort); var sub = conn.GetSubscriber(); @@ -437,6 +466,7 @@ await sub.SubscribeAsync(channel, (_, val) => [Fact] public async Task PubSubGetAllCorrectOrder() { + SkipIfWouldUseInProcessServer(); await using (var conn = Create(configuration: TestConfig.Current.RemoteServerAndPort, syncTimeout: 20000, log: Writer)) { var sub = conn.GetSubscriber(); @@ -507,6 +537,7 @@ async Task RunLoop() [Fact] public async Task PubSubGetAllCorrectOrder_OnMessage_Sync() { + SkipIfWouldUseInProcessServer(); await using (var conn = Create(configuration: TestConfig.Current.RemoteServerAndPort, syncTimeout: 20000, log: Writer)) { var sub = conn.GetSubscriber(); @@ -573,6 +604,7 @@ public async Task PubSubGetAllCorrectOrder_OnMessage_Sync() [Fact] public async Task PubSubGetAllCorrectOrder_OnMessage_Async() { + SkipIfWouldUseInProcessServer(); await using (var conn = Create(configuration: TestConfig.Current.RemoteServerAndPort, syncTimeout: 20000, log: Writer)) { var sub = conn.GetSubscriber(); diff --git a/tests/StackExchange.Redis.Tests/TestBase.cs b/tests/StackExchange.Redis.Tests/TestBase.cs index 1eb0e8aab..94dc1f74b 100644 --- a/tests/StackExchange.Redis.Tests/TestBase.cs +++ b/tests/StackExchange.Redis.Tests/TestBase.cs @@ -211,8 +211,6 @@ public void Teardown() } Assert.Skip($"There were {privateFailCount} private and {sharedFailCount.Value} ambient exceptions; expected {expectedFailCount}."); } - var pool = SocketManager.Shared?.SchedulerPool; - Log($"Service Counts: (Scheduler) Queue: {pool?.TotalServicedByQueue.ToString()}, Pool: {pool?.TotalServicedByPool.ToString()}, Workers: {pool?.WorkerCount.ToString()}, Available: {pool?.AvailableCount.ToString()}"); } protected static IServer GetServer(IConnectionMultiplexer muxer) @@ -588,6 +586,7 @@ protected static async Task UntilConditionAsync(TimeSpan maxWaitTime, Func // simplified usage to get an interchangeable dedicated vs shared in-process server, useful for debugging protected virtual bool UseDedicatedInProcessServer => false; // use the shared server by default + internal ClientFactory ConnectFactory(bool allowAdmin = false, string? channelPrefix = null, bool shared = true) { if (UseDedicatedInProcessServer) @@ -598,6 +597,16 @@ internal ClientFactory ConnectFactory(bool allowAdmin = false, string? channelPr return new ClientFactory(this, allowAdmin, channelPrefix, shared, null); } + protected void SkipIfWouldUseInProcessServer(string? reason = null) + { + Assert.SkipWhen(_inProcServerFixture != null || UseDedicatedInProcessServer, reason ?? "In-process server is in use."); + } + + protected void SkipIfWouldUseRealServer(string? reason = null) + { + Assert.SkipUnless(_inProcServerFixture != null || UseDedicatedInProcessServer, reason ?? "Real server is in use."); + } + internal sealed class ClientFactory : IDisposable, IAsyncDisposable { private readonly TestBase _testBase; @@ -626,6 +635,7 @@ public IInternalConnectionMultiplexer CreateClient() { var config = _server.GetClientConfig(); config.AllowAdmin = _allowAdmin; + config.Protocol = TestContext.Current.GetProtocol(); if (_channelPrefix is not null) { config.ChannelPrefix = RedisChannel.Literal(_channelPrefix); diff --git a/toys/StackExchange.Redis.Server/RedisClient.Output.cs b/toys/StackExchange.Redis.Server/RedisClient.Output.cs index cfb3e1dcb..2ce34044d 100644 --- a/toys/StackExchange.Redis.Server/RedisClient.Output.cs +++ b/toys/StackExchange.Redis.Server/RedisClient.Output.cs @@ -210,28 +210,6 @@ static void WritePrefix(IBufferWriter output, char prefix) } } - static RespPrefix ToResp2(RespPrefix type) - { - switch (type) - { - case RespPrefix.Boolean: - return RespPrefix.Integer; - case RespPrefix.Double: - case RespPrefix.BigInteger: - return RespPrefix.SimpleString; - case RespPrefix.BulkError: - return RespPrefix.SimpleError; - case RespPrefix.VerbatimString: - return RespPrefix.BulkString; - case RespPrefix.Map: - case RespPrefix.Set: - case RespPrefix.Push: - case RespPrefix.Attribute: - return RespPrefix.Array; - default: return type; - } - } - static ResultType ToResultType(RespPrefix type) => type switch { @@ -257,4 +235,28 @@ static ResultType ToResultType(RespPrefix type) => }; } } + + public RespPrefix ApplyProtocol(RespPrefix type) => IsResp2 ? ToResp2(type) : type; + + private static RespPrefix ToResp2(RespPrefix type) + { + switch (type) + { + case RespPrefix.Boolean: + return RespPrefix.Integer; + case RespPrefix.Double: + case RespPrefix.BigInteger: + return RespPrefix.SimpleString; + case RespPrefix.BulkError: + return RespPrefix.SimpleError; + case RespPrefix.VerbatimString: + return RespPrefix.BulkString; + case RespPrefix.Map: + case RespPrefix.Set: + case RespPrefix.Push: + case RespPrefix.Attribute: + return RespPrefix.Array; + default: return type; + } + } } diff --git a/toys/StackExchange.Redis.Server/RespServer.cs b/toys/StackExchange.Redis.Server/RespServer.cs index 7b22a1361..5826d97f1 100644 --- a/toys/StackExchange.Redis.Server/RespServer.cs +++ b/toys/StackExchange.Redis.Server/RespServer.cs @@ -297,6 +297,7 @@ public async Task RunClientAsync(IDuplexPipe pipe, RedisServer.Node node = null, { node ??= DefaultNode; client = AddClient(node, state); + OnClientConnected(client, state); Task output = client.WriteOutputAsync(pipe.Output); while (!client.Closed) { From 6803031ce5209975843e06306d981714c0fa3725 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 10 Mar 2026 16:15:13 +0000 Subject: [PATCH 08/24] fix Select --- src/RESPite/Messages/RespReader.cs | 2 +- toys/StackExchange.Redis.Server/RedisServer.cs | 3 +-- toys/StackExchange.Redis.Server/RespReaderExtensions.cs | 2 ++ 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/RESPite/Messages/RespReader.cs b/src/RESPite/Messages/RespReader.cs index 6bc42dd14..07755c80f 100644 --- a/src/RESPite/Messages/RespReader.cs +++ b/src/RESPite/Messages/RespReader.cs @@ -1771,7 +1771,7 @@ public readonly double ReadDouble() /// /// Try to read the current element as a value. /// - public bool TryReadDouble(out double value, bool allowTokens = true) + public readonly bool TryReadDouble(out double value, bool allowTokens = true) { var span = Buffer(stackalloc byte[RespConstants.MaxRawBytesNumber + 1]); diff --git a/toys/StackExchange.Redis.Server/RedisServer.cs b/toys/StackExchange.Redis.Server/RedisServer.cs index 8d7e344bb..2e8df61a5 100644 --- a/toys/StackExchange.Redis.Server/RedisServer.cs +++ b/toys/StackExchange.Redis.Server/RedisServer.cs @@ -1237,8 +1237,7 @@ protected virtual TypedRedisValue Role(RedisClient client, in RedisRequest reque protected virtual TypedRedisValue Select(RedisClient client, in RedisRequest request) { var raw = request.GetValue(1); - if (!raw.IsInteger) return TypedRedisValue.Error("ERR invalid DB index"); - int db = (int)raw; + if (!raw.TryParse(out int db)) return TypedRedisValue.Error("ERR invalid DB index"); if (db < 0 || db >= Databases) return TypedRedisValue.Error("ERR DB index is out of range"); client.Database = db; return TypedRedisValue.OK; diff --git a/toys/StackExchange.Redis.Server/RespReaderExtensions.cs b/toys/StackExchange.Redis.Server/RespReaderExtensions.cs index e4a6df46f..5e703ca2f 100644 --- a/toys/StackExchange.Redis.Server/RespReaderExtensions.cs +++ b/toys/StackExchange.Redis.Server/RespReaderExtensions.cs @@ -20,6 +20,8 @@ public RedisValue ReadRedisValue() { RespPrefix.Boolean => reader.ReadBoolean(), RespPrefix.Integer => reader.ReadInt64(), + _ when reader.TryReadInt64(out var i64) => i64, + _ when reader.TryReadDouble(out var fp64) => fp64, _ => reader.ReadByteArray(), }; } From 7744acce317e4083002882eceb3d32534fcec12e Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 10 Mar 2026 16:41:34 +0000 Subject: [PATCH 09/24] fix "fromBytes" logic to be more formal, and more importantly: to keep Nick happy --- .../AsciiHashGenerator.cs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/eng/StackExchange.Redis.Build/AsciiHashGenerator.cs b/eng/StackExchange.Redis.Build/AsciiHashGenerator.cs index 7c037856f..4fb411454 100644 --- a/eng/StackExchange.Redis.Build/AsciiHashGenerator.cs +++ b/eng/StackExchange.Redis.Build/AsciiHashGenerator.cs @@ -211,8 +211,26 @@ private static string GetRawValue(string name, AttributeData? asciiHashAttribute var arg = method.Parameters[0]; if (arg is not { IsOptional: false, RefKind: RefKind.None or RefKind.In or RefKind.Ref or RefKind.RefReadOnlyParameter }) return default; + + static bool IsBytes(ITypeSymbol type) + { + // byte[] + if (type is IArrayTypeSymbol { ElementType: { SpecialType: SpecialType.System_Byte } }) + return true; + + // Span or ReadOnlySpan + if (type is INamedTypeSymbol { TypeKind: TypeKind.Struct, Arity: 1, Name: "Span" or "ReadOnlySpan", + ContainingNamespace: { Name: "System", ContainingNamespace.IsGlobalNamespace: true }, + TypeArguments: { Length: 1 } ta } + && ta[0].SpecialType == SpecialType.System_Byte) + { + return true; + } + return false; + } + var fromType = arg.Type.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); - bool fromBytes = fromType is "byte[]" || fromType.EndsWith("Span"); + bool fromBytes = IsBytes(arg.Type); var from = (fromType, arg.Name, fromBytes, arg.RefKind); arg = method.Parameters[1]; From b6dd8185b17cde40408a42288a44b8daa14eee8b Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 10 Mar 2026 16:45:37 +0000 Subject: [PATCH 10/24] make DebugAssertValid logic #if DEBUG --- src/RESPite/Buffers/CycleBuffer.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/RESPite/Buffers/CycleBuffer.cs b/src/RESPite/Buffers/CycleBuffer.cs index f2488ff5d..c82a5fb65 100644 --- a/src/RESPite/Buffers/CycleBuffer.cs +++ b/src/RESPite/Buffers/CycleBuffer.cs @@ -272,6 +272,7 @@ private void DebugAssertValid(long expectedCommittedLength, [CallerMemberName] s [Conditional("DEBUG")] private void DebugAssertValid() { +#if DEBUG if (startSegment is null) { Debug.Assert( @@ -288,6 +289,7 @@ private void DebugAssertValid() // check running indices startSegment?.DebugAssertValidChain(); +#endif } public long GetCommittedLength() From a635f6d03ff0a0b35202cfdc4b5ac132e41870d1 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 10 Mar 2026 17:04:53 +0000 Subject: [PATCH 11/24] cleanup a great many #if --- src/RESPite/Buffers/CycleBuffer.cs | 4 +- src/RESPite/Internal/Raw.cs | 4 +- src/RESPite/Messages/RespFrameScanner.cs | 4 +- .../RespReader.AggregateEnumerator.cs | 2 +- src/RESPite/Messages/RespReader.cs | 10 ++--- src/RESPite/Shared/AsciiHash.cs | 2 +- src/RESPite/Shared/FrameworkShims.Encoding.cs | 2 +- src/RESPite/Shared/FrameworkShims.Stream.cs | 2 +- src/RESPite/Shared/NullableHacks.cs | 4 +- .../ChannelMessageQueue.cs | 25 +----------- .../Configuration/LoggingTunnel.cs | 8 ++-- .../ConfigurationOptions.cs | 8 ++-- .../ConnectionMultiplexer.cs | 2 +- .../ExtensionMethods.Internal.cs | 2 +- src/StackExchange.Redis/Format.cs | 10 ++--- src/StackExchange.Redis/FrameworkShims.cs | 4 +- src/StackExchange.Redis/Interfaces/IServer.cs | 3 ++ src/StackExchange.Redis/LoggerExtensions.cs | 2 +- .../Maintenance/AzureMaintenanceEvent.cs | 5 +-- src/StackExchange.Redis/Message.cs | 8 ++-- src/StackExchange.Redis/NullableHacks.cs | 4 +- src/StackExchange.Redis/PerfCounterHelper.cs | 2 +- src/StackExchange.Redis/PhysicalBridge.cs | 40 +++++++++---------- src/StackExchange.Redis/PhysicalConnection.cs | 4 +- src/StackExchange.Redis/RawResult.cs | 2 +- src/StackExchange.Redis/RedisKey.cs | 2 +- .../ServerSelectionStrategy.cs | 2 +- src/StackExchange.Redis/SkipLocalsInit.cs | 2 +- src/StackExchange.Redis/TaskExtensions.cs | 2 +- tests/RESPite.Tests/TestDuplexStream.cs | 6 +-- .../StackExchange.Redis.Benchmarks/Program.cs | 5 ++- .../StackExchange.Redis.Tests/DuplexStream.cs | 8 ++-- .../ExceptionFactoryTests.cs | 2 +- .../FailoverTests.cs | 2 +- .../PubSubKeyNotificationTests.cs | 2 +- tests/StackExchange.Redis.Tests/SSLTests.cs | 2 +- tests/StackExchange.Redis.Tests/TestBase.cs | 2 +- .../StackExchange.Redis.Server/RedisClient.cs | 4 +- .../RespReaderExtensions.cs | 2 +- 39 files changed, 91 insertions(+), 115 deletions(-) diff --git a/src/RESPite/Buffers/CycleBuffer.cs b/src/RESPite/Buffers/CycleBuffer.cs index c82a5fb65..14774b357 100644 --- a/src/RESPite/Buffers/CycleBuffer.cs +++ b/src/RESPite/Buffers/CycleBuffer.cs @@ -727,7 +727,7 @@ public void Write(in ReadOnlySequence value) { if (value.IsSingleSegment) { -#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1 +#if NET Write(value.FirstSpan); #else Write(value.First.Span); @@ -742,7 +742,7 @@ static void WriteMultiSegment(ref CycleBuffer @this, in ReadOnlySequence v { foreach (var segment in value) { -#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1 +#if NET @this.Write(value.FirstSpan); #else @this.Write(value.First.Span); diff --git a/src/RESPite/Internal/Raw.cs b/src/RESPite/Internal/Raw.cs index 65d0c5059..c2330a027 100644 --- a/src/RESPite/Internal/Raw.cs +++ b/src/RESPite/Internal/Raw.cs @@ -3,7 +3,7 @@ using System.Runtime.InteropServices; using System.Text; -#if NETCOREAPP3_0_OR_GREATER +#if NET using System.Runtime.Intrinsics; using System.Runtime.Intrinsics.X86; #endif @@ -95,7 +95,7 @@ public static uint Create32(ReadOnlySpan bytes, int length) public static uint ArrayPrefix_9_4 = Create32("*9\r\n"u8, 4); public static ulong ArrayPrefix_10_5 = Create64("*10\r\n"u8, 5); -#if NETCOREAPP3_0_OR_GREATER +#if NET private static uint FirstAndLast(char first, char last) { Debug.Assert(first < 128 && last < 128, "ASCII please"); diff --git a/src/RESPite/Messages/RespFrameScanner.cs b/src/RESPite/Messages/RespFrameScanner.cs index a8d88dc5e..35650ca1d 100644 --- a/src/RESPite/Messages/RespFrameScanner.cs +++ b/src/RESPite/Messages/RespFrameScanner.cs @@ -125,7 +125,7 @@ public OperationStatus TryRead(ref RespScanState state, in ReadOnlySequence data) { if (!_pubsub & state.TotalBytes == 0) { -#if NETCOREAPP3_1_OR_GREATER +#if NET var status = TryFastRead(data, ref state); #else var status = TryFastRead(data, ref state); diff --git a/src/RESPite/Messages/RespReader.AggregateEnumerator.cs b/src/RESPite/Messages/RespReader.AggregateEnumerator.cs index 7b8bf9b50..6daf70a84 100644 --- a/src/RESPite/Messages/RespReader.AggregateEnumerator.cs +++ b/src/RESPite/Messages/RespReader.AggregateEnumerator.cs @@ -47,7 +47,7 @@ public AggregateEnumerator(scoped in RespReader reader) /// [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)] #if DEBUG -#if NET6_0 || NET8_0 +#if NET8_0 // strictly net8; net10 and our polyfill have .Message [Experimental("SERDBG")] #else [Experimental("SERDBG", Message = $"Prefer {nameof(Value)}")] diff --git a/src/RESPite/Messages/RespReader.cs b/src/RESPite/Messages/RespReader.cs index 07755c80f..4b26da392 100644 --- a/src/RESPite/Messages/RespReader.cs +++ b/src/RESPite/Messages/RespReader.cs @@ -8,7 +8,7 @@ using System.Text; using RESPite.Internal; -#if NETCOREAPP3_0_OR_GREATER +#if NET using System.Runtime.Intrinsics; using System.Runtime.Intrinsics.X86; #endif @@ -797,7 +797,7 @@ internal readonly ReadOnlySpan Buffer(Span target) return simple; } -#if NET6_0_OR_GREATER +#if NET return BufferSlow(ref Unsafe.NullRef(), target, usePool: false); #else byte[] pooled = []; @@ -1019,7 +1019,7 @@ private void MovePastCurrent() /// public RespReader(scoped in ReadOnlySequence value) -#if NETCOREAPP3_0_OR_GREATER +#if NET : this(value.FirstSpan) #else : this(value.First.Span) @@ -1046,7 +1046,7 @@ public unsafe bool TryReadNext() { MovePastCurrent(); -#if NETCOREAPP3_0_OR_GREATER +#if NET // check what we have available; don't worry about zero/fetching the next segment; this is only // for SIMD lookup, and zero would only apply when data ends exactly on segment boundaries, which // is incredible niche @@ -1883,7 +1883,7 @@ public readonly bool ReadBoolean() /// The type of enum being parsed. public readonly T ReadEnum(T unknownValue = default) where T : struct, Enum { -#if NET6_0_OR_GREATER +#if NET return ParseChars(static (chars, state) => Enum.TryParse(chars, true, out T value) ? value : state, unknownValue); #else return Enum.TryParse(ReadString(), true, out T value) ? value : unknownValue; diff --git a/src/RESPite/Shared/AsciiHash.cs b/src/RESPite/Shared/AsciiHash.cs index aea0ab268..37b3c5734 100644 --- a/src/RESPite/Shared/AsciiHash.cs +++ b/src/RESPite/Shared/AsciiHash.cs @@ -199,7 +199,7 @@ internal static long ToUC(long hashCS) // Something looks possibly lower-case; we can't just mask it off, // because there are other non-alpha characters in that range. -#if NET || NETSTANDARD2_1_OR_GREATER +#if NET ToUpper(MemoryMarshal.CreateSpan(ref Unsafe.As(ref hashCS), sizeof(long))); return hashCS; #else diff --git a/src/RESPite/Shared/FrameworkShims.Encoding.cs b/src/RESPite/Shared/FrameworkShims.Encoding.cs index 92d3dab7e..2f2c2e89d 100644 --- a/src/RESPite/Shared/FrameworkShims.Encoding.cs +++ b/src/RESPite/Shared/FrameworkShims.Encoding.cs @@ -1,4 +1,4 @@ -#if !(NETCOREAPP || NETSTANDARD2_1_OR_GREATER) +#if !NET // ReSharper disable once CheckNamespace namespace System.Text { diff --git a/src/RESPite/Shared/FrameworkShims.Stream.cs b/src/RESPite/Shared/FrameworkShims.Stream.cs index 56823abc4..3a42e5990 100644 --- a/src/RESPite/Shared/FrameworkShims.Stream.cs +++ b/src/RESPite/Shared/FrameworkShims.Stream.cs @@ -1,7 +1,7 @@ using System.Buffers; using System.Runtime.InteropServices; -#if !(NETCOREAPP || NETSTANDARD2_1_OR_GREATER) +#if !NET // ReSharper disable once CheckNamespace namespace System.IO { diff --git a/src/RESPite/Shared/NullableHacks.cs b/src/RESPite/Shared/NullableHacks.cs index 5f8969c73..704437442 100644 --- a/src/RESPite/Shared/NullableHacks.cs +++ b/src/RESPite/Shared/NullableHacks.cs @@ -8,7 +8,7 @@ namespace System.Diagnostics.CodeAnalysis { -#if NETSTANDARD2_0 || NETCOREAPP2_0 || NETCOREAPP2_1 || NETCOREAPP2_2 || NET45 || NET451 || NET452 || NET46 || NET461 || NET462 || NET47 || NET471 || NET472 || NET48 +#if !NET /// Specifies that null is allowed as an input even if the corresponding type disallows it. [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] internal sealed class AllowNullAttribute : Attribute { } @@ -85,9 +85,7 @@ internal sealed class DoesNotReturnIfAttribute : Attribute /// Gets the condition parameter value. public bool ParameterValue { get; } } -#endif -#if NETSTANDARD2_0 || NETCOREAPP2_0 || NETCOREAPP2_1 || NETCOREAPP2_2 || NETCOREAPP3_0 || NETCOREAPP3_1 || NET45 || NET451 || NET452 || NET46 || NET461 || NET462 || NET47 || NET471 || NET472 || NET48 /// Specifies that the method or property will ensure that the listed field and property members have not-null values. [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] internal sealed class MemberNotNullAttribute : Attribute diff --git a/src/StackExchange.Redis/ChannelMessageQueue.cs b/src/StackExchange.Redis/ChannelMessageQueue.cs index 9f962e52a..f7bd9a4a2 100644 --- a/src/StackExchange.Redis/ChannelMessageQueue.cs +++ b/src/StackExchange.Redis/ChannelMessageQueue.cs @@ -4,10 +4,6 @@ using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; -#if NETCOREAPP3_1 -using System.Diagnostics; -using System.Reflection; -#endif namespace StackExchange.Redis; @@ -76,31 +72,12 @@ public ValueTask ReadAsync(CancellationToken cancellationToken = /// The (approximate) count of items in the Channel. public bool TryGetCount(out int count) { - // This is specific to netcoreapp3.1, because full framework was out of band and the new prop is present -#if NETCOREAPP3_1 - // get this using the reflection - try - { - var prop = - _queue.GetType().GetProperty("ItemsCountForDebugger", BindingFlags.Instance | BindingFlags.NonPublic); - if (prop is not null) - { - count = (int)prop.GetValue(_queue)!; - return true; - } - } - catch (Exception ex) - { - Debug.WriteLine(ex.Message); // but ignore - } -#else var reader = _queue.Reader; if (reader.CanCount) { count = reader.Count; return true; } -#endif count = 0; return false; @@ -334,7 +311,7 @@ internal async Task UnsubscribeAsyncImpl(Exception? error = null, CommandFlags f public Task UnsubscribeAsync(CommandFlags flags = CommandFlags.None) => UnsubscribeAsyncImpl(null, flags); /// -#if NETCOREAPP3_0_OR_GREATER +#if NET public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) // ReSharper disable once MethodSupportsCancellation - provided in GetAsyncEnumerator => _queue.Reader.ReadAllAsync().GetAsyncEnumerator(cancellationToken); diff --git a/src/StackExchange.Redis/Configuration/LoggingTunnel.cs b/src/StackExchange.Redis/Configuration/LoggingTunnel.cs index ccfa4ee63..18216c1f2 100644 --- a/src/StackExchange.Redis/Configuration/LoggingTunnel.cs +++ b/src/StackExchange.Redis/Configuration/LoggingTunnel.cs @@ -359,7 +359,7 @@ private async Task TlsHandshakeAsync(Stream stream, EndPoint endpoint) userCertificateSelectionCallback: _options.CertificateSelectionCallback ?? PhysicalConnection.GetAmbientClientCertificateCallback(), encryptionPolicy: EncryptionPolicy.RequireEncryption); -#if NETCOREAPP3_1_OR_GREATER +#if NET var configOptions = _options.SslClientAuthenticationOptions?.Invoke(host); if (configOptions is not null) { @@ -532,7 +532,7 @@ public override void Close() base.Close(); } -#if NETCOREAPP3_0_OR_GREATER +#if NET public override async ValueTask DisposeAsync() { await _inner.DisposeAsync().ForAwait(); @@ -574,7 +574,7 @@ public override async Task ReadAsync(byte[] buffer, int offset, int count, } return len; } -#if NETCOREAPP3_0_OR_GREATER +#if NET public override int Read(Span buffer) { var len = _inner.Read(buffer); @@ -613,7 +613,7 @@ public override async Task WriteAsync(byte[] buffer, int offset, int count, Canc await _inner.WriteAsync(buffer, offset, count, cancellationToken).ForAwait(); await writesTask; } -#if NETCOREAPP3_0_OR_GREATER +#if NET public override void Write(ReadOnlySpan buffer) { _writes.Write(buffer); diff --git a/src/StackExchange.Redis/ConfigurationOptions.cs b/src/StackExchange.Redis/ConfigurationOptions.cs index 9136d2333..641fccc95 100644 --- a/src/StackExchange.Redis/ConfigurationOptions.cs +++ b/src/StackExchange.Redis/ConfigurationOptions.cs @@ -301,7 +301,7 @@ public bool HighIntegrity /// The file system path to find the certificate at. public void TrustIssuer(string issuerCertificatePath) => CertificateValidationCallback = TrustIssuerCallback(issuerCertificatePath); -#if NET5_0_OR_GREATER +#if NET /// /// Supply a user certificate from a PEM file pair and enable TLS. /// @@ -325,7 +325,7 @@ public void SetUserPfxCertificate(string userCertificatePath, string? password = Ssl = true; } -#if NET5_0_OR_GREATER +#if NET internal static LocalCertificateSelectionCallback CreatePemUserCertificateCallback(string userCertificatePath, string? userKeyPath) { // PEM handshakes not universally supported and causes a runtime error about ephemeral certificates; to avoid, export as PFX @@ -698,7 +698,7 @@ public int ResponseTimeout /// public SocketManager? SocketManager { get; set; } -#if NETCOREAPP3_1_OR_GREATER +#if NET /// /// A provider for a given host, for custom TLS connection options. /// Note: this overrides *all* other TLS and certificate settings, only for advanced use cases. @@ -837,7 +837,7 @@ public static ConfigurationOptions Parse(string configuration, bool ignoreUnknow BeforeSocketConnect = BeforeSocketConnect, EndPoints = EndPoints.Clone(), LoggerFactory = LoggerFactory, -#if NETCOREAPP3_1_OR_GREATER +#if NET SslClientAuthenticationOptions = SslClientAuthenticationOptions, #endif Tunnel = Tunnel, diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index e19de6d52..e42ea27b4 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -1911,7 +1911,7 @@ private static string DeDotifyHost(string input) if (colonPosition > 0) { // Has a port specifier -#if NETCOREAPP +#if NET return string.Concat(input.AsSpan(0, periodPosition), input.AsSpan(colonPosition)); #else return input.Substring(0, periodPosition) + input.Substring(colonPosition); diff --git a/src/StackExchange.Redis/ExtensionMethods.Internal.cs b/src/StackExchange.Redis/ExtensionMethods.Internal.cs index de5a9f2a6..446f6ff88 100644 --- a/src/StackExchange.Redis/ExtensionMethods.Internal.cs +++ b/src/StackExchange.Redis/ExtensionMethods.Internal.cs @@ -11,7 +11,7 @@ internal static bool IsNullOrEmpty([NotNullWhen(false)] this string? s) => internal static bool IsNullOrWhiteSpace([NotNullWhen(false)] this string? s) => string.IsNullOrWhiteSpace(s); -#if !NETCOREAPP3_1_OR_GREATER +#if !NET internal static bool TryDequeue(this Queue queue, [NotNullWhen(true)] out T? result) { if (queue.Count == 0) diff --git a/src/StackExchange.Redis/Format.cs b/src/StackExchange.Redis/Format.cs index a76b77afc..9279bb0f5 100644 --- a/src/StackExchange.Redis/Format.cs +++ b/src/StackExchange.Redis/Format.cs @@ -15,7 +15,7 @@ namespace StackExchange.Redis { internal static class Format { -#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_0_OR_GREATER +#if NET public static int ParseInt32(ReadOnlySpan s) => int.Parse(s, NumberStyles.Integer, NumberFormatInfo.InvariantInfo); public static bool TryParseInt32(ReadOnlySpan s, out int value) => int.TryParse(s, NumberStyles.Integer, NumberFormatInfo.InvariantInfo, out value); #endif @@ -197,7 +197,7 @@ static bool TryParseInfNaN(ReadOnlySpan s, bool positive, out double value } break; } -#if NET6_0_OR_GREATER +#if NET Unsafe.SkipInit(out value); #else value = 0; @@ -281,7 +281,7 @@ static bool TryParseInfNaN(ReadOnlySpan s, bool positive, out double value } break; } -#if NET6_0_OR_GREATER +#if NET Unsafe.SkipInit(out value); #else value = 0; @@ -406,7 +406,7 @@ internal static string GetString(ReadOnlySequence buffer) internal static unsafe string GetString(ReadOnlySpan span) { if (span.IsEmpty) return ""; -#if NETCOREAPP3_1_OR_GREATER +#if NET return Encoding.UTF8.GetString(span); #else fixed (byte* ptr = span) @@ -567,7 +567,7 @@ internal static int FormatInt32(int value, Span destination) internal static bool TryParseVersion(ReadOnlySpan input, [NotNullWhen(true)] out Version? version) { -#if NETCOREAPP3_1_OR_GREATER +#if NET if (Version.TryParse(input, out version)) return true; // allow major-only (Version doesn't do this, because... reasons?) if (TryParseInt32(input, out int i32)) diff --git a/src/StackExchange.Redis/FrameworkShims.cs b/src/StackExchange.Redis/FrameworkShims.cs index c0fe4cb1d..84ccc2d6b 100644 --- a/src/StackExchange.Redis/FrameworkShims.cs +++ b/src/StackExchange.Redis/FrameworkShims.cs @@ -1,6 +1,6 @@ #pragma warning disable SA1403 // single namespace -#if NET5_0_OR_GREATER +#if NET // context: https://github.com/StackExchange/StackExchange.Redis/issues/2619 [assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Runtime.CompilerServices.IsExternalInit))] #else @@ -27,7 +27,7 @@ internal sealed class OverloadResolutionPriorityAttribute(int priority) : Attrib } #endif -#if !(NETCOREAPP || NETSTANDARD2_1_OR_GREATER) +#if !NET namespace System.Text { diff --git a/src/StackExchange.Redis/Interfaces/IServer.cs b/src/StackExchange.Redis/Interfaces/IServer.cs index 8e4178fc9..fdd3d6872 100644 --- a/src/StackExchange.Redis/Interfaces/IServer.cs +++ b/src/StackExchange.Redis/Interfaces/IServer.cs @@ -814,5 +814,8 @@ internal static class IServerExtensions /// The server to simulate failure on. /// The type of failure(s) to simulate. internal static void SimulateConnectionFailure(this IServer server, SimulatedFailureType failureType) => (server as RedisServer)?.SimulateConnectionFailure(failureType); + + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + internal static bool CanSimulateConnectionFailure(this IServer server) => server is not null; // this changes in v3 } } diff --git a/src/StackExchange.Redis/LoggerExtensions.cs b/src/StackExchange.Redis/LoggerExtensions.cs index be51733ce..4a43514e4 100644 --- a/src/StackExchange.Redis/LoggerExtensions.cs +++ b/src/StackExchange.Redis/LoggerExtensions.cs @@ -34,7 +34,7 @@ internal static void LogWithThreadPoolStats(this ILogger? log, string message) _ = PerfCounterHelper.GetThreadPoolStats(out string iocp, out string worker, out string? workItems); -#if NET6_0_OR_GREATER +#if NET // use DISH when possible // similar to: var composed = $"{message}, IOCP: {iocp}, WORKER: {worker}, ..."; on net6+ var dish = new System.Runtime.CompilerServices.DefaultInterpolatedStringHandler(26, 4); diff --git a/src/StackExchange.Redis/Maintenance/AzureMaintenanceEvent.cs b/src/StackExchange.Redis/Maintenance/AzureMaintenanceEvent.cs index f4c7d3e49..0a5874c29 100644 --- a/src/StackExchange.Redis/Maintenance/AzureMaintenanceEvent.cs +++ b/src/StackExchange.Redis/Maintenance/AzureMaintenanceEvent.cs @@ -2,9 +2,6 @@ using System.Globalization; using System.Net; using System.Threading.Tasks; -#if NETCOREAPP -using System.Buffers.Text; -#endif namespace StackExchange.Redis.Maintenance { @@ -58,7 +55,7 @@ internal AzureMaintenanceEvent(string? azureEvent) if (key.Length > 0 && value.Length > 0) { -#if NETCOREAPP +#if NET switch (key) { case var _ when key.SequenceEqual(nameof(NotificationType).AsSpan()): diff --git a/src/StackExchange.Redis/Message.cs b/src/StackExchange.Redis/Message.cs index faf25ba44..0ffcf4256 100644 --- a/src/StackExchange.Redis/Message.cs +++ b/src/StackExchange.Redis/Message.cs @@ -262,7 +262,7 @@ public static Message Create(int db, CommandFlags flags, RedisCommand command, i public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, GeoEntry[] values) { -#if NET6_0_OR_GREATER +#if NET ArgumentNullException.ThrowIfNull(values); #else if (values == null) throw new ArgumentNullException(nameof(values)); @@ -485,7 +485,7 @@ public void Complete() internal static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, RedisValue[] values) { -#if NET6_0_OR_GREATER +#if NET ArgumentNullException.ThrowIfNull(values); #else if (values == null) throw new ArgumentNullException(nameof(values)); @@ -503,7 +503,7 @@ internal static Message Create(int db, CommandFlags flags, RedisCommand command, internal static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key0, in RedisKey key1, RedisValue[] values) { -#if NET6_0_OR_GREATER +#if NET ArgumentNullException.ThrowIfNull(values); #else if (values == null) throw new ArgumentNullException(nameof(values)); @@ -524,7 +524,7 @@ internal static Message Create(int db, CommandFlags flags, RedisCommand command, internal static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key0, RedisValue[] values, in RedisKey key1) { -#if NET6_0_OR_GREATER +#if NET ArgumentNullException.ThrowIfNull(values); #else if (values == null) throw new ArgumentNullException(nameof(values)); diff --git a/src/StackExchange.Redis/NullableHacks.cs b/src/StackExchange.Redis/NullableHacks.cs index 5f8969c73..4ebebf73b 100644 --- a/src/StackExchange.Redis/NullableHacks.cs +++ b/src/StackExchange.Redis/NullableHacks.cs @@ -8,7 +8,7 @@ namespace System.Diagnostics.CodeAnalysis { -#if NETSTANDARD2_0 || NETCOREAPP2_0 || NETCOREAPP2_1 || NETCOREAPP2_2 || NET45 || NET451 || NET452 || NET46 || NET461 || NET462 || NET47 || NET471 || NET472 || NET48 +#if !NET /// Specifies that null is allowed as an input even if the corresponding type disallows it. [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] internal sealed class AllowNullAttribute : Attribute { } @@ -87,7 +87,7 @@ internal sealed class DoesNotReturnIfAttribute : Attribute } #endif -#if NETSTANDARD2_0 || NETCOREAPP2_0 || NETCOREAPP2_1 || NETCOREAPP2_2 || NETCOREAPP3_0 || NETCOREAPP3_1 || NET45 || NET451 || NET452 || NET46 || NET461 || NET462 || NET47 || NET471 || NET472 || NET48 +#if !NET /// Specifies that the method or property will ensure that the listed field and property members have not-null values. [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] internal sealed class MemberNotNullAttribute : Attribute diff --git a/src/StackExchange.Redis/PerfCounterHelper.cs b/src/StackExchange.Redis/PerfCounterHelper.cs index 8d8b6fbb0..763c86f04 100644 --- a/src/StackExchange.Redis/PerfCounterHelper.cs +++ b/src/StackExchange.Redis/PerfCounterHelper.cs @@ -22,7 +22,7 @@ internal static int GetThreadPoolStats(out string iocp, out string worker, out s iocp = $"(Busy={busyIoThreads},Free={freeIoThreads},Min={minIoThreads},Max={maxIoThreads})"; worker = $"(Busy={busyWorkerThreads},Free={freeWorkerThreads},Min={minWorkerThreads},Max={maxWorkerThreads})"; -#if NETCOREAPP +#if NET workItems = $"(Threads={ThreadPool.ThreadCount},QueuedItems={ThreadPool.PendingWorkItemCount},CompletedItems={ThreadPool.CompletedWorkItemCount},Timers={Timer.ActiveCount})"; #else workItems = null; diff --git a/src/StackExchange.Redis/PhysicalBridge.cs b/src/StackExchange.Redis/PhysicalBridge.cs index 9e5808009..f8d8421e9 100644 --- a/src/StackExchange.Redis/PhysicalBridge.cs +++ b/src/StackExchange.Redis/PhysicalBridge.cs @@ -64,7 +64,7 @@ internal sealed class PhysicalBridge : IDisposable internal long? ConnectionId => physical?.ConnectionId; -#if NETCOREAPP +#if NET private readonly SemaphoreSlim _singleWriterMutex = new(1, 1); #else private readonly MutexSlim _singleWriterMutex; @@ -357,7 +357,7 @@ public override string ToString() => { MessagesSinceLastHeartbeat = (int)(Interlocked.Read(ref operationCount) - Interlocked.Read(ref profileLastLog)), ConnectedAt = ConnectedAt, -#if NETCOREAPP +#if NET IsWriterActive = _singleWriterMutex.CurrentCount == 0, #else IsWriterActive = !_singleWriterMutex.IsAvailable, @@ -819,14 +819,14 @@ internal WriteResult WriteMessageTakingWriteLockSync(PhysicalConnection physical return WriteResult.Success; // queued counts as success } -#if NETCOREAPP +#if NET bool gotLock = false; #else LockToken token = default; #endif try { -#if NETCOREAPP +#if NET gotLock = _singleWriterMutex.Wait(0); if (!gotLock) #else @@ -843,7 +843,7 @@ internal WriteResult WriteMessageTakingWriteLockSync(PhysicalConnection physical // no backlog... try to wait with the timeout; // if we *still* can't get it: that counts as // an actual timeout -#if NETCOREAPP +#if NET gotLock = _singleWriterMutex.Wait(TimeoutMilliseconds); if (!gotLock) return TimedOutBeforeWrite(message); #else @@ -866,7 +866,7 @@ internal WriteResult WriteMessageTakingWriteLockSync(PhysicalConnection physical finally { UnmarkActiveMessage(message); -#if NETCOREAPP +#if NET if (gotLock) { _singleWriterMutex.Release(); @@ -1144,7 +1144,7 @@ private void ProcessBridgeBacklog() { // Importantly: don't assume we have a physical connection here // We are very likely to hit a state where it's not re-established or even referenced here -#if NETCOREAPP +#if NET bool gotLock = false; #else LockToken token = default; @@ -1166,7 +1166,7 @@ private void ProcessBridgeBacklog() if (_backlog.IsEmpty) return; // nothing to do // try and get the lock; if unsuccessful, retry -#if NETCOREAPP +#if NET gotLock = _singleWriterMutex.Wait(TimeoutMilliseconds); if (gotLock) break; // got the lock; now go do something with it #else @@ -1231,7 +1231,7 @@ private void ProcessBridgeBacklog() } finally { -#if NETCOREAPP +#if NET if (gotLock) { _singleWriterMutex.Release(); @@ -1282,7 +1282,7 @@ internal ValueTask WriteMessageTakingWriteLockAsync(PhysicalConnect } bool releaseLock = true; // fine to default to true, as it doesn't matter until token is a "success" -#if NETCOREAPP +#if NET bool gotLock = false; #else LockToken token = default; @@ -1290,7 +1290,7 @@ internal ValueTask WriteMessageTakingWriteLockAsync(PhysicalConnect try { // try to acquire it synchronously -#if NETCOREAPP +#if NET gotLock = _singleWriterMutex.Wait(0); if (!gotLock) #else @@ -1308,7 +1308,7 @@ internal ValueTask WriteMessageTakingWriteLockAsync(PhysicalConnect // no backlog... try to wait with the timeout; // if we *still* can't get it: that counts as // an actual timeout -#if NETCOREAPP +#if NET var pending = _singleWriterMutex.WaitAsync(TimeoutMilliseconds); if (pending.Status != TaskStatus.RanToCompletion) return WriteMessageTakingWriteLockAsync_Awaited(pending, physical, message); @@ -1329,7 +1329,7 @@ internal ValueTask WriteMessageTakingWriteLockAsync(PhysicalConnect if (!flush.IsCompletedSuccessfully) { releaseLock = false; // so we don't release prematurely -#if NETCOREAPP +#if NET return CompleteWriteAndReleaseLockAsync(flush, message); #else return CompleteWriteAndReleaseLockAsync(token, flush, message); @@ -1349,7 +1349,7 @@ internal ValueTask WriteMessageTakingWriteLockAsync(PhysicalConnect } finally { -#if NETCOREAPP +#if NET if (gotLock) #else if (token.Success) @@ -1359,7 +1359,7 @@ internal ValueTask WriteMessageTakingWriteLockAsync(PhysicalConnect if (releaseLock) { -#if NETCOREAPP +#if NET _singleWriterMutex.Release(); #else token.Dispose(); @@ -1370,7 +1370,7 @@ internal ValueTask WriteMessageTakingWriteLockAsync(PhysicalConnect } private async ValueTask WriteMessageTakingWriteLockAsync_Awaited( -#if NETCOREAPP +#if NET Task pending, #else ValueTask pending, @@ -1378,13 +1378,13 @@ private async ValueTask WriteMessageTakingWriteLockAsync_Awaited( PhysicalConnection physical, Message message) { -#if NETCOREAPP +#if NET bool gotLock = false; #endif try { -#if NETCOREAPP +#if NET gotLock = await pending.ForAwait(); if (!gotLock) return TimedOutBeforeWrite(message); #else @@ -1408,7 +1408,7 @@ private async ValueTask WriteMessageTakingWriteLockAsync_Awaited( finally { UnmarkActiveMessage(message); -#if NETCOREAPP +#if NET if (gotLock) { _singleWriterMutex.Release(); @@ -1440,7 +1440,7 @@ private async ValueTask CompleteWriteAndReleaseLockAsync( } finally { -#if NETCOREAPP +#if NET _singleWriterMutex.Release(); #endif } diff --git a/src/StackExchange.Redis/PhysicalConnection.cs b/src/StackExchange.Redis/PhysicalConnection.cs index f35c6d81d..6ba8b4cde 100644 --- a/src/StackExchange.Redis/PhysicalConnection.cs +++ b/src/StackExchange.Redis/PhysicalConnection.cs @@ -1576,7 +1576,7 @@ public ConnectionStatus GetStatus() return ConfigurationOptions.CreatePfxUserCertificateCallback(certificatePath, password, storageFlags); } -#if NET5_0_OR_GREATER +#if NET certificatePath = Environment.GetEnvironmentVariable("SERedis_ClientCertPemPath"); if (!string.IsNullOrEmpty(certificatePath) && File.Exists(certificatePath)) { @@ -1635,7 +1635,7 @@ internal async ValueTask ConnectedAsync(Socket? socket, ILogger? log, Sock { try { -#if NETCOREAPP3_1_OR_GREATER +#if NET var configOptions = config.SslClientAuthenticationOptions?.Invoke(host); if (configOptions is not null) { diff --git a/src/StackExchange.Redis/RawResult.cs b/src/StackExchange.Redis/RawResult.cs index 9496aa91c..2b2b3989a 100644 --- a/src/StackExchange.Redis/RawResult.cs +++ b/src/StackExchange.Redis/RawResult.cs @@ -441,7 +441,7 @@ private static GeoPosition AsGeoPosition(in Sequence coords) s = Format.GetString(Payload.First.Span); return Resp3Type == ResultType.VerbatimString ? GetVerbatimString(s, out verbatimPrefix) : s; } -#if NET6_0_OR_GREATER +#if NET // use system-provided sequence decoder return Encoding.UTF8.GetString(in _payload); #else diff --git a/src/StackExchange.Redis/RedisKey.cs b/src/StackExchange.Redis/RedisKey.cs index e18e0fb7c..2d192c244 100644 --- a/src/StackExchange.Redis/RedisKey.cs +++ b/src/StackExchange.Redis/RedisKey.cs @@ -419,7 +419,7 @@ internal int CopyTo(Span destination) case string s: if (s.Length != 0) { -#if NETCOREAPP +#if NET written += Encoding.UTF8.GetBytes(s, destination); #else unsafe diff --git a/src/StackExchange.Redis/ServerSelectionStrategy.cs b/src/StackExchange.Redis/ServerSelectionStrategy.cs index 7d599724a..48ba32a77 100644 --- a/src/StackExchange.Redis/ServerSelectionStrategy.cs +++ b/src/StackExchange.Redis/ServerSelectionStrategy.cs @@ -49,7 +49,7 @@ internal sealed class ServerSelectionStrategy private readonly ConnectionMultiplexer? multiplexer; private int anyStartOffset = SharedRandom.Next(); // initialize to a random value so routing isn't uniform - #if NET6_0_OR_GREATER + #if NET private static Random SharedRandom => Random.Shared; #else private static Random SharedRandom { get; } = new(); diff --git a/src/StackExchange.Redis/SkipLocalsInit.cs b/src/StackExchange.Redis/SkipLocalsInit.cs index 353b00142..494a37a57 100644 --- a/src/StackExchange.Redis/SkipLocalsInit.cs +++ b/src/StackExchange.Redis/SkipLocalsInit.cs @@ -4,7 +4,7 @@ // the most relevant to us, so we have audited that no "stackalloc" use expects the buffers to be zero'd initially [module:System.Runtime.CompilerServices.SkipLocalsInit] -#if !NET5_0_OR_GREATER +#if !NET // when not available, we can spoof it in a private type namespace System.Runtime.CompilerServices { diff --git a/src/StackExchange.Redis/TaskExtensions.cs b/src/StackExchange.Redis/TaskExtensions.cs index a0994a0b6..5b5684da1 100644 --- a/src/StackExchange.Redis/TaskExtensions.cs +++ b/src/StackExchange.Redis/TaskExtensions.cs @@ -25,7 +25,7 @@ internal static Task ObserveErrors(this Task task) return task; } -#if !NET6_0_OR_GREATER +#if !NET // suboptimal polyfill version of the .NET 6+ API, but reasonable for light use internal static Task WaitAsync(this Task task, CancellationToken cancellationToken) { diff --git a/tests/RESPite.Tests/TestDuplexStream.cs b/tests/RESPite.Tests/TestDuplexStream.cs index ee1cae2c7..3456f0cb7 100644 --- a/tests/RESPite.Tests/TestDuplexStream.cs +++ b/tests/RESPite.Tests/TestDuplexStream.cs @@ -149,7 +149,7 @@ public override Task ReadAsync(byte[] buffer, int offset, int count, Cancel return _inboundStream.ReadAsync(buffer, offset, count, cancellationToken); } -#if NETCOREAPP || NETSTANDARD2_1_OR_GREATER +#if NET public override int Read(Span buffer) { return _inboundStream.Read(buffer); @@ -172,7 +172,7 @@ public override Task WriteAsync(byte[] buffer, int offset, int count, Cancellati return _outbound.WriteAsync(buffer, offset, count, cancellationToken); } -#if NETCOREAPP || NETSTANDARD2_1_OR_GREATER +#if NET public override void Write(ReadOnlySpan buffer) { _outbound.Write(buffer); @@ -216,7 +216,7 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } -#if NETCOREAPP || NETSTANDARD2_1_OR_GREATER +#if NET public override async ValueTask DisposeAsync() { _inbound.Writer.Complete(); diff --git a/tests/StackExchange.Redis.Benchmarks/Program.cs b/tests/StackExchange.Redis.Benchmarks/Program.cs index 311202877..3999a61b4 100644 --- a/tests/StackExchange.Redis.Benchmarks/Program.cs +++ b/tests/StackExchange.Redis.Benchmarks/Program.cs @@ -9,13 +9,14 @@ internal static class Program private static void Main(string[] args) { #if DEBUG - var obj = new FastHashBenchmarks(); + var obj = new AsciiHashBenchmarks(); foreach (var size in obj.Sizes) { Console.WriteLine($"Size: {size}"); obj.Size = size; obj.Setup(); - obj.Hash64(); + obj.HashCS_C(); + obj.HashCS_B(); } #else BenchmarkSwitcher.FromAssembly(typeof(Program).GetTypeInfo().Assembly).Run(args); diff --git a/tests/StackExchange.Redis.Tests/DuplexStream.cs b/tests/StackExchange.Redis.Tests/DuplexStream.cs index fc3d23955..8a9b8c737 100644 --- a/tests/StackExchange.Redis.Tests/DuplexStream.cs +++ b/tests/StackExchange.Redis.Tests/DuplexStream.cs @@ -43,7 +43,7 @@ public override int Read(byte[] buffer, int offset, int count) public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => _inputStream.ReadAsync(buffer, offset, count, cancellationToken); -#if NET6_0_OR_GREATER +#if NET public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => _inputStream.ReadAsync(buffer, cancellationToken); @@ -60,7 +60,7 @@ public override void Write(byte[] buffer, int offset, int count) public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => _outputStream.WriteAsync(buffer, offset, count, cancellationToken); -#if NET6_0_OR_GREATER +#if NET public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) => _outputStream.WriteAsync(buffer, cancellationToken); @@ -100,7 +100,7 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } -#if NET6_0_OR_GREATER +#if NET public override async ValueTask DisposeAsync() { await _inputStream.DisposeAsync().ConfigureAwait(false); @@ -121,7 +121,7 @@ public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, As public override void EndWrite(IAsyncResult asyncResult) => _outputStream.EndWrite(asyncResult); -#if NET6_0_OR_GREATER +#if NET public override void CopyTo(Stream destination, int bufferSize) => _inputStream.CopyTo(destination, bufferSize); #endif diff --git a/tests/StackExchange.Redis.Tests/ExceptionFactoryTests.cs b/tests/StackExchange.Redis.Tests/ExceptionFactoryTests.cs index 53f28f163..65d6946dc 100644 --- a/tests/StackExchange.Redis.Tests/ExceptionFactoryTests.cs +++ b/tests/StackExchange.Redis.Tests/ExceptionFactoryTests.cs @@ -123,7 +123,7 @@ public async Task TimeoutException() Assert.Contains("async-ops: ", ex.Message); Assert.Contains("conn-sec: n/a", ex.Message); Assert.Contains("aoc: 1", ex.Message); -#if NETCOREAPP +#if NET // ...POOL: (Threads=33,QueuedItems=0,CompletedItems=5547,Timers=60)... Assert.Contains("POOL: ", ex.Message); Assert.Contains("Threads=", ex.Message); diff --git a/tests/StackExchange.Redis.Tests/FailoverTests.cs b/tests/StackExchange.Redis.Tests/FailoverTests.cs index 825c8efce..9c330a3f3 100644 --- a/tests/StackExchange.Redis.Tests/FailoverTests.cs +++ b/tests/StackExchange.Redis.Tests/FailoverTests.cs @@ -1,4 +1,4 @@ -#if NET6_0_OR_GREATER +#if NET using System; using System.IO; using System.Threading; diff --git a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs index 723921d45..83023c3a7 100644 --- a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs @@ -50,7 +50,7 @@ private IInternalConnectionMultiplexer Create(bool withChannelPrefix) => private RedisKey SelectKey(RedisKey[] keys) => keys[SharedRandom.Next(0, keys.Length)]; -#if NET6_0_OR_GREATER +#if NET private static Random SharedRandom => Random.Shared; #else private static Random SharedRandom { get; } = new(); diff --git a/tests/StackExchange.Redis.Tests/SSLTests.cs b/tests/StackExchange.Redis.Tests/SSLTests.cs index c9c5cc2bb..96d964b23 100644 --- a/tests/StackExchange.Redis.Tests/SSLTests.cs +++ b/tests/StackExchange.Redis.Tests/SSLTests.cs @@ -163,7 +163,7 @@ public async Task ConnectToSSLServer(bool useSsl, bool specifyHost) } } -#if NETCOREAPP3_1_OR_GREATER +#if NET #pragma warning disable CS0618 // Type or member is obsolete // Docker configured with only TLS_AES_256_GCM_SHA384 for testing [Theory] diff --git a/tests/StackExchange.Redis.Tests/TestBase.cs b/tests/StackExchange.Redis.Tests/TestBase.cs index 94dc1f74b..7859263d4 100644 --- a/tests/StackExchange.Redis.Tests/TestBase.cs +++ b/tests/StackExchange.Redis.Tests/TestBase.cs @@ -562,7 +562,7 @@ void Callback() for (int i = 0; i < threads; i++) { var thd = threadArr[i]; -#if !NET6_0_OR_GREATER +#if !NET if (thd.IsAlive) thd.Abort(); #endif } diff --git a/toys/StackExchange.Redis.Server/RedisClient.cs b/toys/StackExchange.Redis.Server/RedisClient.cs index bb930d5f9..01f164aa8 100644 --- a/toys/StackExchange.Redis.Server/RedisClient.cs +++ b/toys/StackExchange.Redis.Server/RedisClient.cs @@ -10,7 +10,7 @@ namespace StackExchange.Redis.Server { public partial class RedisClient(RedisServer.Node node) : IDisposable #pragma warning disable SA1001 - #if NET6_0_OR_GREATER + #if NET , ISpanFormattable #else , IFormattable @@ -29,7 +29,7 @@ public override string ToString() } string IFormattable.ToString(string format, IFormatProvider formatProvider) => ToString(); -#if NET6_0_OR_GREATER +#if NET public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider provider) { if (!Id.TryFormat(destination, out charsWritten)) diff --git a/toys/StackExchange.Redis.Server/RespReaderExtensions.cs b/toys/StackExchange.Redis.Server/RespReaderExtensions.cs index 5e703ca2f..8ee5f921c 100644 --- a/toys/StackExchange.Redis.Server/RespReaderExtensions.cs +++ b/toys/StackExchange.Redis.Server/RespReaderExtensions.cs @@ -204,7 +204,7 @@ internal bool AnyNull() } } -#if !(NET || NETSTANDARD2_1_OR_GREATER) +#if !NET extension(Task task) { public bool IsCompletedSuccessfully => task.Status is TaskStatus.RanToCompletion; From fc7ec66f453983a2704b8fa276a30dd2512152b6 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 10 Mar 2026 17:09:33 +0000 Subject: [PATCH 12/24] join NET7 => NET8 --- src/RESPite/Internal/Raw.cs | 2 +- src/RESPite/Messages/RespReader.Span.cs | 2 +- src/RESPite/Messages/RespReader.cs | 2 +- .../Interfaces/IDatabase.VectorSets.cs | 2 +- .../Interfaces/IDatabaseAsync.VectorSets.cs | 2 +- src/StackExchange.Redis/VectorSetAddRequest.cs | 2 +- .../VectorSetSimilaritySearchResult.cs | 3 +-- tests/RESPite.Tests/RespReaderTests.cs | 10 +++------- 8 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/RESPite/Internal/Raw.cs b/src/RESPite/Internal/Raw.cs index c2330a027..9df318630 100644 --- a/src/RESPite/Internal/Raw.cs +++ b/src/RESPite/Internal/Raw.cs @@ -127,7 +127,7 @@ private static uint FirstAndLast(char first, char last) private static Vector256 CreateUInt32(uint value) { -#if NET7_0_OR_GREATER +#if NET8_0_OR_GREATER return Vector256.Create(value); #else return Vector256.Create(value, value, value, value, value, value, value, value); diff --git a/src/RESPite/Messages/RespReader.Span.cs b/src/RESPite/Messages/RespReader.Span.cs index ea3f0f536..cfea585c4 100644 --- a/src/RESPite/Messages/RespReader.Span.cs +++ b/src/RESPite/Messages/RespReader.Span.cs @@ -14,7 +14,7 @@ namespace RESPite.Messages; How we actually implement the underlying buffer depends on the capabilities of the runtime. */ -#if NET7_0_OR_GREATER && USE_UNSAFE_SPAN +#if NET8_0_OR_GREATER && USE_UNSAFE_SPAN public ref partial struct RespReader { diff --git a/src/RESPite/Messages/RespReader.cs b/src/RESPite/Messages/RespReader.cs index 4b26da392..1304dbdd0 100644 --- a/src/RESPite/Messages/RespReader.cs +++ b/src/RESPite/Messages/RespReader.cs @@ -910,7 +910,7 @@ internal readonly T ParseChars(Parser parser, TState } } -#if NET7_0_OR_GREATER +#if NET8_0_OR_GREATER /// /// Reads the current element using . /// diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.VectorSets.cs b/src/StackExchange.Redis/Interfaces/IDatabase.VectorSets.cs index 60b32844e..8e6444ea8 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabase.VectorSets.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabase.VectorSets.cs @@ -162,7 +162,7 @@ bool VectorSetAdd( bool VectorSetSetAttributesJson( RedisKey key, RedisValue member, -#if NET7_0_OR_GREATER +#if NET8_0_OR_GREATER [StringSyntax(StringSyntaxAttribute.Json)] #endif string attributesJson, diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.VectorSets.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.VectorSets.cs index 56666ce3d..a2d9b4058 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.VectorSets.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.VectorSets.cs @@ -81,7 +81,7 @@ Task VectorSetAddAsync( Task VectorSetSetAttributesJsonAsync( RedisKey key, RedisValue member, -#if NET7_0_OR_GREATER +#if NET8_0_OR_GREATER [StringSyntax(StringSyntaxAttribute.Json)] #endif string attributesJson, diff --git a/src/StackExchange.Redis/VectorSetAddRequest.cs b/src/StackExchange.Redis/VectorSetAddRequest.cs index 8428d6031..8262d4750 100644 --- a/src/StackExchange.Redis/VectorSetAddRequest.cs +++ b/src/StackExchange.Redis/VectorSetAddRequest.cs @@ -24,7 +24,7 @@ internal VectorSetAddRequest() public static VectorSetAddRequest Member( RedisValue element, ReadOnlyMemory values, -#if NET7_0_OR_GREATER +#if NET8_0_OR_GREATER [StringSyntax(StringSyntaxAttribute.Json)] #endif string? attributesJson = null) diff --git a/src/StackExchange.Redis/VectorSetSimilaritySearchResult.cs b/src/StackExchange.Redis/VectorSetSimilaritySearchResult.cs index e16f91fdb..8706313e7 100644 --- a/src/StackExchange.Redis/VectorSetSimilaritySearchResult.cs +++ b/src/StackExchange.Redis/VectorSetSimilaritySearchResult.cs @@ -23,9 +23,8 @@ public readonly struct VectorSetSimilaritySearchResult(RedisValue member, double /// /// The JSON attributes associated with the member when WITHATTRIBS is used, null otherwise. /// -#if NET7_0_OR_GREATER +#if NET8_0_OR_GREATER [StringSyntax(StringSyntaxAttribute.Json)] -#endif public string? AttributesJson { get; } = attributesJson; /// diff --git a/tests/RESPite.Tests/RespReaderTests.cs b/tests/RESPite.Tests/RespReaderTests.cs index 505fa480c..b80c840f4 100644 --- a/tests/RESPite.Tests/RespReaderTests.cs +++ b/tests/RESPite.Tests/RespReaderTests.cs @@ -155,14 +155,12 @@ public void BlobString(RespPayload payload) Assert.Equal("hello world", reader.ReadString()); Assert.Equal("hello world", reader.ReadString(out var prefix)); Assert.Equal("", prefix); -#if NET7_0_OR_GREATER +#if NET8_0_OR_GREATER Assert.Equal("hello world", reader.ParseChars()); -#endif /* interestingly, string does not implement IUtf8SpanParsable -#if NET8_0_OR_GREATER Assert.Equal("hello world", reader.ParseBytes()); -#endif */ +#endif reader.DemandEnd(); } @@ -220,12 +218,10 @@ public void Number(RespPayload payload) Assert.Equal(1234, reader.ReadInt32()); Assert.Equal(1234D, reader.ReadDouble()); Assert.Equal(1234M, reader.ReadDecimal()); -#if NET7_0_OR_GREATER +#if NET8_0_OR_GREATER Assert.Equal(1234, reader.ParseChars()); Assert.Equal(1234D, reader.ParseChars()); Assert.Equal(1234M, reader.ParseChars()); -#endif -#if NET8_0_OR_GREATER Assert.Equal(1234, reader.ParseBytes()); Assert.Equal(1234D, reader.ParseBytes()); Assert.Equal(1234M, reader.ParseBytes()); From bec19191c2d8d7b41ce2967419203c8c30f5d411 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 10 Mar 2026 17:11:56 +0000 Subject: [PATCH 13/24] join net9 with net10 --- src/RESPite/Internal/BlockBufferSerializer.cs | 2 +- src/RESPite/Messages/RespReader.AggregateEnumerator.cs | 4 ++-- src/RESPite/Messages/RespReader.cs | 6 +++--- src/RESPite/Shared/FrameworkShims.cs | 2 +- src/StackExchange.Redis/FrameworkShims.cs | 2 +- .../StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs | 4 ++-- toys/StackExchange.Redis.Server/RedisClient.Output.cs | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/RESPite/Internal/BlockBufferSerializer.cs b/src/RESPite/Internal/BlockBufferSerializer.cs index b8b3649cd..5f90f66cb 100644 --- a/src/RESPite/Internal/BlockBufferSerializer.cs +++ b/src/RESPite/Internal/BlockBufferSerializer.cs @@ -36,7 +36,7 @@ public virtual ReadOnlyMemory Serialize( ReadOnlySpan command, in TRequest request, IRespFormatter formatter) -#if NET9_0_OR_GREATER +#if NET10_0_OR_GREATER where TRequest : allows ref struct #endif { diff --git a/src/RESPite/Messages/RespReader.AggregateEnumerator.cs b/src/RESPite/Messages/RespReader.AggregateEnumerator.cs index 6daf70a84..412ef6ab5 100644 --- a/src/RESPite/Messages/RespReader.AggregateEnumerator.cs +++ b/src/RESPite/Messages/RespReader.AggregateEnumerator.cs @@ -198,7 +198,7 @@ public void FillAll(scoped Span target, Projection pr } public void FillAll(scoped Span target, ref TState state, Projection projection) -#if NET9_0_OR_GREATER +#if NET10_0_OR_GREATER where TState : allows ref struct #endif { @@ -234,7 +234,7 @@ public void FillAll( Projection first, Projection second, Func combine) -#if NET9_0_OR_GREATER +#if NET10_0_OR_GREATER where TState : allows ref struct #endif { diff --git a/src/RESPite/Messages/RespReader.cs b/src/RESPite/Messages/RespReader.cs index 1304dbdd0..b2288b574 100644 --- a/src/RESPite/Messages/RespReader.cs +++ b/src/RESPite/Messages/RespReader.cs @@ -177,7 +177,7 @@ public readonly bool AggregateLengthIs(int count) public delegate T Projection(ref RespReader value); public delegate TResult Projection(ref TState state, ref RespReader value) -#if NET9_0_OR_GREATER +#if NET10_0_OR_GREATER where TState : allows ref struct #endif ; @@ -1907,7 +1907,7 @@ public readonly T ReadEnum(T unknownValue = default) where T : struct, Enum /// Additional state required by the projection. /// The type of data to be projected. public TResult[]? ReadArray(ref TState state, Projection projection, bool scalar = false) -#if NET9_0_OR_GREATER +#if NET10_0_OR_GREATER where TState : allows ref struct #endif { @@ -1928,7 +1928,7 @@ public readonly T ReadEnum(T unknownValue = default) where T : struct, Enum /// Additional state required by the projection. /// The type of data to be projected. public TResult[]? ReadPastArray(ref TState state, Projection projection, bool scalar = false) -#if NET9_0_OR_GREATER +#if NET10_0_OR_GREATER where TState : allows ref struct #endif #pragma warning restore RS0026 diff --git a/src/RESPite/Shared/FrameworkShims.cs b/src/RESPite/Shared/FrameworkShims.cs index 0f7aa641c..ceb344b9e 100644 --- a/src/RESPite/Shared/FrameworkShims.cs +++ b/src/RESPite/Shared/FrameworkShims.cs @@ -1,6 +1,6 @@ #pragma warning disable SA1403 // single namespace -#if !NET9_0_OR_GREATER +#if !NET10_0_OR_GREATER namespace System.Runtime.CompilerServices { // see https://learn.microsoft.com/dotnet/api/system.runtime.compilerservices.overloadresolutionpriorityattribute diff --git a/src/StackExchange.Redis/FrameworkShims.cs b/src/StackExchange.Redis/FrameworkShims.cs index 84ccc2d6b..ce954406d 100644 --- a/src/StackExchange.Redis/FrameworkShims.cs +++ b/src/StackExchange.Redis/FrameworkShims.cs @@ -15,7 +15,7 @@ internal static class IsExternalInit { } } #endif -#if !NET9_0_OR_GREATER +#if !NET10_0_OR_GREATER namespace System.Runtime.CompilerServices { // see https://learn.microsoft.com/dotnet/api/system.runtime.compilerservices.overloadresolutionpriorityattribute diff --git a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs index 83023c3a7..a65d0c631 100644 --- a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs @@ -338,7 +338,7 @@ private void OnNotification( var recvKey = notification.GetKey(); Assert.True(observedCounts.TryGetValue(recvKey.ToString(), out var counter)); -#if NET9_0_OR_GREATER +#if NET10_0_OR_GREATER // it would be more efficient to stash the alt-lookup, but that would make our API here non-viable, // since we need to support multiple frameworks var viaAlt = FindViaAltLookup(notification, observedCounts.GetAlternateLookup>()); @@ -396,7 +396,7 @@ private async Task SendAndObserveAsync( } } -#if NET9_0_OR_GREATER +#if NET10_0_OR_GREATER // demonstrate that we can use the alt-lookup APIs to avoid string allocations private static Counter? FindViaAltLookup( in KeyNotification notification, diff --git a/toys/StackExchange.Redis.Server/RedisClient.Output.cs b/toys/StackExchange.Redis.Server/RedisClient.Output.cs index 2ce34044d..e27d693be 100644 --- a/toys/StackExchange.Redis.Server/RedisClient.Output.cs +++ b/toys/StackExchange.Redis.Server/RedisClient.Output.cs @@ -106,7 +106,7 @@ public async Task WriteOutputAsync(PipeWriter writer, CancellationToken cancella if (count != 0) { -#if NET9_0_OR_GREATER +#if NET10_0_OR_GREATER Node?.Server?.OnFlush(this, count, writer.CanGetUnflushedBytes ? writer.UnflushedBytes : -1); #else Node?.Server?.OnFlush(this, count, -1); From c1ff305bab350bd795f25fac71bccf32a5415aa7 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 11 Mar 2026 08:29:34 +0000 Subject: [PATCH 14/24] fix high integrity token check on AUTH/HELLO --- src/StackExchange.Redis/PhysicalBridge.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/StackExchange.Redis/PhysicalBridge.cs b/src/StackExchange.Redis/PhysicalBridge.cs index f8d8421e9..e3a07eaa9 100644 --- a/src/StackExchange.Redis/PhysicalBridge.cs +++ b/src/StackExchange.Redis/PhysicalBridge.cs @@ -1622,7 +1622,7 @@ private WriteResult WriteMessageToServerInsideWriteLock(PhysicalConnection conne if (_nextHighIntegrityToken is not 0 && !connection.TransactionActive // validated in the UNWATCH/EXEC/DISCARD - && message.Command is not RedisCommand.AUTH or RedisCommand.HELLO) // if auth fails, ECHO may also fail; avoid confusion + && message.Command is not (RedisCommand.AUTH or RedisCommand.HELLO)) // if auth fails, ECHO may also fail; avoid confusion { // make sure this value exists early to avoid a race condition // if the response comes back super quickly From 63848d6708f42aa566c2794243ef8ac31aaa7aa8 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 11 Mar 2026 08:31:30 +0000 Subject: [PATCH 15/24] borked a #endif --- src/StackExchange.Redis/VectorSetSimilaritySearchResult.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/StackExchange.Redis/VectorSetSimilaritySearchResult.cs b/src/StackExchange.Redis/VectorSetSimilaritySearchResult.cs index 8706313e7..c87e04bc1 100644 --- a/src/StackExchange.Redis/VectorSetSimilaritySearchResult.cs +++ b/src/StackExchange.Redis/VectorSetSimilaritySearchResult.cs @@ -25,6 +25,7 @@ public readonly struct VectorSetSimilaritySearchResult(RedisValue member, double /// #if NET8_0_OR_GREATER [StringSyntax(StringSyntaxAttribute.Json)] +#endif public string? AttributesJson { get; } = attributesJson; /// From 29260b484fcca1bf0bff9aa5df59b97a51498f77 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 11 Mar 2026 08:48:33 +0000 Subject: [PATCH 16/24] Use a better way of refing the build project --- .github/workflows/CI.yml | 4 ---- .github/workflows/codeql.yml | 4 ---- Directory.Build.props | 7 +++++++ src/Directory.Build.props | 3 --- src/RESPite/RESPite.csproj | 2 -- src/StackExchange.Redis/StackExchange.Redis.csproj | 4 ---- .../StackExchange.Redis.Benchmarks.csproj | 1 - .../StackExchange.Redis.Tests.csproj | 1 - .../StackExchange.Redis.Server.csproj | 1 - 9 files changed, 7 insertions(+), 20 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 3e49997e9..0b62bc917 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -31,8 +31,6 @@ jobs: 6.0.x 8.0.x 10.0.x - - name: .NET Build (eng prebuild) - run: dotnet build eng/StackExchange.Redis.Build/StackExchange.Redis.Build.csproj -c Release /p:CI=true - name: .NET Build run: dotnet build Build.csproj -c Release /p:CI=true - name: StackExchange.Redis.Tests @@ -134,8 +132,6 @@ jobs: redis-cli -p 26381 INFO SERVER | grep redis_version || echo "Failed to get version for port 26381" continue-on-error: true - - name: .NET Build (eng prebuild) - run: dotnet build eng/StackExchange.Redis.Build/StackExchange.Redis.Build.csproj -c Release /p:CI=true - name: .NET Build run: dotnet build Build.csproj -c Release /p:CI=true - name: StackExchange.Redis.Tests diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b3fc43f95..a03767211 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -53,10 +53,6 @@ jobs: # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality - - if: matrix.language == 'csharp' - name: .NET Build (eng prebuild) - run: dotnet build eng/StackExchange.Redis.Build/StackExchange.Redis.Build.csproj -c Release /p:CI=true - - if: matrix.language == 'csharp' name: .NET Build run: dotnet build Build.csproj -c Release /p:CI=true diff --git a/Directory.Build.props b/Directory.Build.props index 169fe44d1..1f6b9fc01 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -42,4 +42,11 @@ + + + + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 40f59348d..27366ae98 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -11,7 +11,4 @@ - - - diff --git a/src/RESPite/RESPite.csproj b/src/RESPite/RESPite.csproj index f82bcdf57..6fb6cbfed 100644 --- a/src/RESPite/RESPite.csproj +++ b/src/RESPite/RESPite.csproj @@ -19,8 +19,6 @@ - diff --git a/src/StackExchange.Redis/StackExchange.Redis.csproj b/src/StackExchange.Redis/StackExchange.Redis.csproj index 1975dda6a..02a5ed519 100644 --- a/src/StackExchange.Redis/StackExchange.Redis.csproj +++ b/src/StackExchange.Redis/StackExchange.Redis.csproj @@ -54,10 +54,6 @@ - - - - diff --git a/tests/StackExchange.Redis.Benchmarks/StackExchange.Redis.Benchmarks.csproj b/tests/StackExchange.Redis.Benchmarks/StackExchange.Redis.Benchmarks.csproj index 6b921a92f..47359ac85 100644 --- a/tests/StackExchange.Redis.Benchmarks/StackExchange.Redis.Benchmarks.csproj +++ b/tests/StackExchange.Redis.Benchmarks/StackExchange.Redis.Benchmarks.csproj @@ -13,6 +13,5 @@ - diff --git a/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj b/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj index dfd7b1c09..4c312d448 100644 --- a/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj +++ b/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj @@ -33,6 +33,5 @@ - diff --git a/toys/StackExchange.Redis.Server/StackExchange.Redis.Server.csproj b/toys/StackExchange.Redis.Server/StackExchange.Redis.Server.csproj index 661b01e14..68d690497 100644 --- a/toys/StackExchange.Redis.Server/StackExchange.Redis.Server.csproj +++ b/toys/StackExchange.Redis.Server/StackExchange.Redis.Server.csproj @@ -15,6 +15,5 @@ - From 7f7c0b007d084d7b00c2828845c6134772376d6b Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 11 Mar 2026 08:58:47 +0000 Subject: [PATCH 17/24] clarify public API files --- src/RESPite/PublicAPI/net6.0/PublicAPI.Shipped.txt | 1 - src/RESPite/PublicAPI/net6.0/PublicAPI.Unshipped.txt | 1 - src/RESPite/RESPite.csproj | 2 +- .../PublicAPI/net8.0/PublicAPI.Shipped.txt | 4 ---- .../PublicAPI/netcoreapp3.1/PublicAPI.Shipped.txt | 2 -- src/StackExchange.Redis/StackExchange.Redis.csproj | 2 +- 6 files changed, 2 insertions(+), 10 deletions(-) delete mode 100644 src/RESPite/PublicAPI/net6.0/PublicAPI.Shipped.txt delete mode 100644 src/RESPite/PublicAPI/net6.0/PublicAPI.Unshipped.txt delete mode 100644 src/StackExchange.Redis/PublicAPI/net8.0/PublicAPI.Shipped.txt delete mode 100644 src/StackExchange.Redis/PublicAPI/netcoreapp3.1/PublicAPI.Shipped.txt diff --git a/src/RESPite/PublicAPI/net6.0/PublicAPI.Shipped.txt b/src/RESPite/PublicAPI/net6.0/PublicAPI.Shipped.txt deleted file mode 100644 index ab058de62..000000000 --- a/src/RESPite/PublicAPI/net6.0/PublicAPI.Shipped.txt +++ /dev/null @@ -1 +0,0 @@ -#nullable enable diff --git a/src/RESPite/PublicAPI/net6.0/PublicAPI.Unshipped.txt b/src/RESPite/PublicAPI/net6.0/PublicAPI.Unshipped.txt deleted file mode 100644 index ab058de62..000000000 --- a/src/RESPite/PublicAPI/net6.0/PublicAPI.Unshipped.txt +++ /dev/null @@ -1 +0,0 @@ -#nullable enable diff --git a/src/RESPite/RESPite.csproj b/src/RESPite/RESPite.csproj index 6fb6cbfed..fef03625b 100644 --- a/src/RESPite/RESPite.csproj +++ b/src/RESPite/RESPite.csproj @@ -38,7 +38,7 @@ - + diff --git a/src/StackExchange.Redis/PublicAPI/net8.0/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI/net8.0/PublicAPI.Shipped.txt deleted file mode 100644 index fae4f65ce..000000000 --- a/src/StackExchange.Redis/PublicAPI/net8.0/PublicAPI.Shipped.txt +++ /dev/null @@ -1,4 +0,0 @@ -StackExchange.Redis.ConfigurationOptions.SslClientAuthenticationOptions.get -> System.Func? -StackExchange.Redis.ConfigurationOptions.SslClientAuthenticationOptions.set -> void -System.Runtime.CompilerServices.IsExternalInit (forwarded, contained in System.Runtime) -StackExchange.Redis.ConfigurationOptions.SetUserPemCertificate(string! userCertificatePath, string? userKeyPath = null) -> void \ No newline at end of file diff --git a/src/StackExchange.Redis/PublicAPI/netcoreapp3.1/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI/netcoreapp3.1/PublicAPI.Shipped.txt deleted file mode 100644 index 194e1b51b..000000000 --- a/src/StackExchange.Redis/PublicAPI/netcoreapp3.1/PublicAPI.Shipped.txt +++ /dev/null @@ -1,2 +0,0 @@ -StackExchange.Redis.ConfigurationOptions.SslClientAuthenticationOptions.get -> System.Func? -StackExchange.Redis.ConfigurationOptions.SslClientAuthenticationOptions.set -> void \ No newline at end of file diff --git a/src/StackExchange.Redis/StackExchange.Redis.csproj b/src/StackExchange.Redis/StackExchange.Redis.csproj index 02a5ed519..4bff8ce53 100644 --- a/src/StackExchange.Redis/StackExchange.Redis.csproj +++ b/src/StackExchange.Redis/StackExchange.Redis.csproj @@ -42,7 +42,7 @@ - + From 1cb363f46be7ab68e39558243977fce8d626a6b6 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 11 Mar 2026 09:21:52 +0000 Subject: [PATCH 18/24] exclude Build.csproj from refing the analyzer project! --- Directory.Build.props | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 1f6b9fc01..273acae25 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -43,10 +43,9 @@ - - - + + From b1ef9a5bd04c77a43eea212cd73d0cc251d831a5 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 11 Mar 2026 10:30:21 +0000 Subject: [PATCH 19/24] - improve "moved" test for "did the client already know the new node?" - fix where RESP3 default is specified for the toy server --- .../InProcessTestServer.cs | 1 + .../StackExchange.Redis.Tests/MovedUnitTests.cs | 17 +++++++++++++---- tests/StackExchange.Redis.Tests/TestBase.cs | 1 - 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/StackExchange.Redis.Tests/InProcessTestServer.cs b/tests/StackExchange.Redis.Tests/InProcessTestServer.cs index 988a657fd..0f7a1f56f 100644 --- a/tests/StackExchange.Redis.Tests/InProcessTestServer.cs +++ b/tests/StackExchange.Redis.Tests/InProcessTestServer.cs @@ -85,6 +85,7 @@ public ConfigurationOptions GetClientConfig(bool withPubSub = true /*, WriteMode AsyncTimeout = 5000, AllowAdmin = true, Tunnel = Tunnel, + Protocol = TestContext.Current.GetProtocol(), // WriteMode = (BufferedStreamWriter.WriteMode)writeMode, }; if (!string.IsNullOrEmpty(Password)) config.Password = Password; diff --git a/tests/StackExchange.Redis.Tests/MovedUnitTests.cs b/tests/StackExchange.Redis.Tests/MovedUnitTests.cs index 1671d6cc3..5618adf27 100644 --- a/tests/StackExchange.Redis.Tests/MovedUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/MovedUnitTests.cs @@ -11,6 +11,7 @@ namespace StackExchange.Redis.Tests; /// When a MOVED error points to the same endpoint, the client should reconnect before retrying, /// allowing the DNS record/proxy/load balancer to route to a different underlying server host. /// +[RunPerProtocol] public class MovedUnitTests(ITestOutputHelper log) { private RedisKey Me([CallerMemberName] string callerName = "") => callerName; @@ -48,13 +49,16 @@ public async Task CrossSlotDisallowed(ServerType serverType) } [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task KeyMigrationFollowed(bool allowFollowRedirects) + [InlineData(true, false)] + [InlineData(false, false)] + [InlineData(true, true)] + [InlineData(false, true)] + public async Task KeyMigrationFollowed(bool allowFollowRedirects, bool toNewUnknownNode) { RedisKey key = Me(); using var server = new InProcessTestServer(log) { ServerType = ServerType.Cluster }; - var secondNode = server.AddEmptyNode(); + // depending on the test, we might not want the client to know about the second node yet + var secondNode = toNewUnknownNode ? null : server.AddEmptyNode(); await using var muxer = await server.ConnectAsync(); var db = muxer.GetDatabase(); @@ -63,6 +67,11 @@ public async Task KeyMigrationFollowed(bool allowFollowRedirects) var value = await db.StringGetAsync(key); Assert.Equal("value", (string?)value); + if (toNewUnknownNode) // if deferred, the client doesn't know about this yet + { + secondNode = server.AddEmptyNode(); + } + server.Migrate(key, secondNode); if (allowFollowRedirects) diff --git a/tests/StackExchange.Redis.Tests/TestBase.cs b/tests/StackExchange.Redis.Tests/TestBase.cs index 7859263d4..62b841f08 100644 --- a/tests/StackExchange.Redis.Tests/TestBase.cs +++ b/tests/StackExchange.Redis.Tests/TestBase.cs @@ -635,7 +635,6 @@ public IInternalConnectionMultiplexer CreateClient() { var config = _server.GetClientConfig(); config.AllowAdmin = _allowAdmin; - config.Protocol = TestContext.Current.GetProtocol(); if (_channelPrefix is not null) { config.ChannelPrefix = RedisChannel.Literal(_channelPrefix); From e431d7f1ca84d4c1298fac8780e03ce5dba31df9 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 11 Mar 2026 10:51:12 +0000 Subject: [PATCH 20/24] make test server output more terse --- .../StackExchange.Redis.Tests/InProcessTestServer.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/StackExchange.Redis.Tests/InProcessTestServer.cs b/tests/StackExchange.Redis.Tests/InProcessTestServer.cs index 0f7a1f56f..2b9cd934d 100644 --- a/tests/StackExchange.Redis.Tests/InProcessTestServer.cs +++ b/tests/StackExchange.Redis.Tests/InProcessTestServer.cs @@ -40,20 +40,20 @@ public override TypedRedisValue Execute(RedisClient client, in RedisRequest requ } else if (result.IsAggregate) { - Log($"[{client}] {request.Command} => {(char)type} ({type}, {result.Span.Length})"); + Log($"[{client}] {request.Command} => {(char)type}{result.Span.Length}"); } else { try { var s = result.AsRedisValue().ToString() ?? "(null)"; - const int MAX_CHARS = 16; + const int MAX_CHARS = 32; s = s.Length <= MAX_CHARS ? s : s.Substring(0, MAX_CHARS) + "..."; - Log($"[{client}] {request.Command} => {(char)type} ({type}) {s}"); + Log($"[{client}] {request.Command} => {(char)type}{s}"); } catch { - Log($"[{client}] {request.Command} => {(char)type} ({type})"); + Log($"[{client}] {request.Command} => {(char)type}"); } } return result; @@ -133,11 +133,11 @@ protected override void OnOutOfBand(RedisClient client, TypedRedisValue message) && message.Span is { IsEmpty: false } span && !span[0].IsAggregate) { - _log?.WriteLine($"[{client}] => {(char)type} ({type}, {message.Span.Length}): {span[0].AsRedisValue()}"); + _log?.WriteLine($"[{client}] => {(char)type}{message.Span.Length} {span[0].AsRedisValue()}"); } else { - _log?.WriteLine($"[{client}] => {(char)type} ({type})"); + _log?.WriteLine($"[{client}] => {(char)type}"); } base.OnOutOfBand(client, message); From cffd70c632d8e7082da409bdb808c223940dd6c4 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 11 Mar 2026 11:14:41 +0000 Subject: [PATCH 21/24] toy server: don't pretend SENTINEL exists --- toys/StackExchange.Redis.Server/RedisServer.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/toys/StackExchange.Redis.Server/RedisServer.cs b/toys/StackExchange.Redis.Server/RedisServer.cs index 2e8df61a5..aa26e34a6 100644 --- a/toys/StackExchange.Redis.Server/RedisServer.cs +++ b/toys/StackExchange.Redis.Server/RedisServer.cs @@ -782,10 +782,6 @@ protected override Node GetNode(int hashSlot) return base.GetNode(hashSlot); } - [RedisCommand(-1)] - protected virtual TypedRedisValue Sentinel(RedisClient client, in RedisRequest request) - => request.CommandNotFound(); - [RedisCommand(-3)] protected virtual TypedRedisValue Lpush(RedisClient client, in RedisRequest request) { From 14c79f8ec76068b9dd22d21449c701cf519a2370 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 11 Mar 2026 15:51:13 +0000 Subject: [PATCH 22/24] rev minor --- version.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.json b/version.json index 8d660cad3..c2ded472b 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { - "version": "2.11", - "versionHeightOffset": -10, + "version": "2.12", + "versionHeightOffset": 0, "assemblyVersion": "2.0", "publicReleaseRefSpec": [ "^refs/heads/main$", From 364bbac36bcad551c5c8c781ec91a4de9da76395 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 11 Mar 2026 16:17:36 +0000 Subject: [PATCH 23/24] bump From 294b839bea75407b0c41c4cc819f4600682ed8a5 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 12 Mar 2026 16:17:21 +0000 Subject: [PATCH 24/24] Include endpoint data in log, for example `[127.0.0.1:6379 #1] CLIENT => +OK` --- .../InProcessTestServer.cs | 4 +- .../MovedTestServer.cs | 2 +- .../StackExchange.Redis.Server/RedisClient.cs | 42 +++++++++++++------ 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/tests/StackExchange.Redis.Tests/InProcessTestServer.cs b/tests/StackExchange.Redis.Tests/InProcessTestServer.cs index 2b9cd934d..6f80215dd 100644 --- a/tests/StackExchange.Redis.Tests/InProcessTestServer.cs +++ b/tests/StackExchange.Redis.Tests/InProcessTestServer.cs @@ -122,7 +122,7 @@ public override void Log(string message) protected override void OnMoved(RedisClient client, int hashSlot, Node node) { - _log?.WriteLine($"Client {client.Id} being redirected: {hashSlot} to {node}"); + _log?.WriteLine($"[{client}] being redirected: slot {hashSlot} to {node}"); base.OnMoved(client, hashSlot, node); } @@ -206,7 +206,7 @@ private sealed class InProcTunnel( if (!clientTcs.Task.Wait(1000)) throw new TimeoutException("Client not connected"); var client = clientTcs.Task.Result; server._log?.WriteLine( - $"[{client}] connected to {Format.ToString(endpoint)} ({connectionType} mapped to {server.ServerType} node {node})"); + $"[{client}] connected ({connectionType} mapped to {server.ServerType} node {node})"); var readStream = serverToClient.Reader.AsStream(); var writeStream = clientToServer.Writer.AsStream(); diff --git a/tests/StackExchange.Redis.Tests/MovedTestServer.cs b/tests/StackExchange.Redis.Tests/MovedTestServer.cs index 17ed92c35..89a8567d0 100644 --- a/tests/StackExchange.Redis.Tests/MovedTestServer.cs +++ b/tests/StackExchange.Redis.Tests/MovedTestServer.cs @@ -71,7 +71,7 @@ public override void OnClientConnected(RedisClient client, object state) { if (client is MovedTestClient movedClient) { - Log($"Client {client.Id} connected (assigned to {movedClient.AssignedHost}), total connections: {TotalClientCount}"); + Log($"[{client}] connected (assigned to {movedClient.AssignedHost}), total connections: {TotalClientCount}"); } base.OnClientConnected(client, state); } diff --git a/toys/StackExchange.Redis.Server/RedisClient.cs b/toys/StackExchange.Redis.Server/RedisClient.cs index 01f164aa8..1f09b3916 100644 --- a/toys/StackExchange.Redis.Server/RedisClient.cs +++ b/toys/StackExchange.Redis.Server/RedisClient.cs @@ -23,40 +23,58 @@ public override string ToString() { if (Protocol is RedisProtocol.Resp2) { - return IsSubscriber ? $"{Id}:sub" : Id.ToString(); + return IsSubscriber ? $"{node.Host}:{node.Port} #{Id}:sub" : $"{node.Host}:{node.Port} #{Id}"; } - return $"{Id}:r3"; + return $"{node.Host}:{node.Port} #{Id}:r3"; } string IFormattable.ToString(string format, IFormatProvider formatProvider) => ToString(); #if NET public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider provider) { - if (!Id.TryFormat(destination, out charsWritten)) + charsWritten = 0; + if (!(TryWrite(ref destination, node.Host.AsSpan(), ref charsWritten) + && TryWrite(ref destination, ":".AsSpan(), ref charsWritten) + && TryWriteInt32(ref destination, node.Port, ref charsWritten) + && TryWrite(ref destination, " #".AsSpan(), ref charsWritten) + && TryWriteInt32(ref destination, Id, ref charsWritten))) { return false; } - destination = destination.Slice(charsWritten); if (Protocol is RedisProtocol.Resp2) { if (IsSubscriber) { - if (!":sub".AsSpan().TryCopyTo(destination)) - { - return false; - } - charsWritten += 4; + if (!TryWrite(ref destination, ":sub".AsSpan(), ref charsWritten)) return false; } } else { - if (!":r3".AsSpan().TryCopyTo(destination)) + if (!TryWrite(ref destination, ":r3".AsSpan(), ref charsWritten)) return false; + } + return true; + + static bool TryWrite(ref Span destination, ReadOnlySpan value, ref int charsWritten) + { + if (value.Length > destination.Length) + { + return false; + } + value.CopyTo(destination); + destination = destination.Slice(value.Length); + charsWritten += value.Length; + return true; + } + static bool TryWriteInt32(ref Span destination, int value, ref int charsWritten) + { + if (!value.TryFormat(destination, out var len)) { return false; } - charsWritten += 3; + destination = destination.Slice(len); + charsWritten += len; + return true; } - return true; } #endif