diff --git a/StackExchange.Redis.sln.DotSettings b/StackExchange.Redis.sln.DotSettings index 8dd9095d9..339a73c59 100644 --- a/StackExchange.Redis.sln.DotSettings +++ b/StackExchange.Redis.sln.DotSettings @@ -26,4 +26,5 @@ True True True - True \ No newline at end of file + True + True \ No newline at end of file diff --git a/docs/exp/SER004.md b/docs/exp/SER004.md new file mode 100644 index 000000000..91f5d87c4 --- /dev/null +++ b/docs/exp/SER004.md @@ -0,0 +1,15 @@ +# RESPite + +RESPite is an experimental library that provides high-performance low-level RESP (Redis, etc) parsing and serialization. +It is used as the IO core for StackExchange.Redis v3+. You should not (yet) use it directly unless you have a very +good reason to do so. + +```xml +$(NoWarn);SER004 +``` + +or more granularly / locally in C#: + +``` c# +#pragma warning disable SER004 +``` diff --git a/docs/exp/SER005.md b/docs/exp/SER005.md new file mode 100644 index 000000000..03e7b7bb4 --- /dev/null +++ b/docs/exp/SER005.md @@ -0,0 +1,21 @@ +# Unit Testing + +Unit testing is great! Yay, do more of that! + +This type is provided for external unit testing, in particular by people using modules or server features +not directly implemented by SE.Redis - for example to verify messsage parsing or formatting without +talking to a RESP server. + +These types are considered slightly more... *mercurial*. We encourage you to use them, but *occasionally* +(not just for fun) you might need to update your test code if we tweak something. This should not impact +"real" library usage. + +```xml +$(NoWarn);SER005 +``` + +or more granularly / locally in C#: + +``` c# +#pragma warning disable SER005 +``` 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/Shared/AsciiHash.Public.cs b/src/RESPite/Shared/AsciiHash.Public.cs deleted file mode 100644 index dd31cb415..000000000 --- a/src/RESPite/Shared/AsciiHash.Public.cs +++ /dev/null @@ -1,10 +0,0 @@ -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 index 37b3c5734..2a995425d 100644 --- a/src/RESPite/Shared/AsciiHash.cs +++ b/src/RESPite/Shared/AsciiHash.cs @@ -1,4 +1,3 @@ -using System; using System.Buffers.Binary; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -7,8 +6,6 @@ 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 @@ -22,7 +19,7 @@ namespace RESPite; Inherited = false)] [Conditional("DEBUG")] // evaporate in release [Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)] -sealed partial class AsciiHashAttribute(string token = "") : Attribute +public sealed partial class AsciiHashAttribute(string token = "") : Attribute { /// /// The token expected when parsing data, if different from the implied value. The implied @@ -38,7 +35,7 @@ sealed partial class AsciiHashAttribute(string token = "") : Attribute // note: instance members are in AsciiHash.Instance.cs. [Experimental(Experiments.Respite, UrlFormat = Experiments.UrlFormat)] -readonly partial struct AsciiHash +public readonly partial struct AsciiHash { /// /// In-place ASCII upper-case conversion. diff --git a/src/StackExchange.Redis/APITypes/LatencyHistoryEntry.cs b/src/StackExchange.Redis/APITypes/LatencyHistoryEntry.cs index 003708e6a..0daa00377 100644 --- a/src/StackExchange.Redis/APITypes/LatencyHistoryEntry.cs +++ b/src/StackExchange.Redis/APITypes/LatencyHistoryEntry.cs @@ -1,4 +1,5 @@ using System; +using RESPite.Messages; namespace StackExchange.Redis; @@ -11,18 +12,14 @@ public readonly struct LatencyHistoryEntry private sealed class Processor : ArrayResultProcessor { - protected override bool TryParse(in RawResult raw, out LatencyHistoryEntry parsed) + protected override bool TryParse(ref RespReader reader, out LatencyHistoryEntry parsed) { - if (raw.Resp2TypeArray == ResultType.Array) + if (reader.IsAggregate + && reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out var timestamp) + && reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out var duration)) { - var items = raw.GetItems(); - if (items.Length >= 2 - && items[0].TryGetInt64(out var timestamp) - && items[1].TryGetInt64(out var duration)) - { - parsed = new LatencyHistoryEntry(timestamp, duration); - return true; - } + parsed = new LatencyHistoryEntry(timestamp, duration); + return true; } parsed = default; return false; diff --git a/src/StackExchange.Redis/APITypes/LatencyLatestEntry.cs b/src/StackExchange.Redis/APITypes/LatencyLatestEntry.cs index d1bc70e42..c1f012495 100644 --- a/src/StackExchange.Redis/APITypes/LatencyLatestEntry.cs +++ b/src/StackExchange.Redis/APITypes/LatencyLatestEntry.cs @@ -1,4 +1,5 @@ using System; +using RESPite.Messages; namespace StackExchange.Redis; @@ -11,17 +12,17 @@ public readonly struct LatencyLatestEntry private sealed class Processor : ArrayResultProcessor { - protected override bool TryParse(in RawResult raw, out LatencyLatestEntry parsed) + protected override bool TryParse(ref RespReader reader, out LatencyLatestEntry parsed) { - if (raw.Resp2TypeArray == ResultType.Array) + if (reader.IsAggregate && reader.TryMoveNext() && reader.IsScalar) { - var items = raw.GetItems(); - if (items.Length >= 4 - && items[1].TryGetInt64(out var timestamp) - && items[2].TryGetInt64(out var duration) - && items[3].TryGetInt64(out var maxDuration)) + var eventName = reader.ReadString()!; + + if (reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out var timestamp) + && reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out var duration) + && reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out var maxDuration)) { - parsed = new LatencyLatestEntry(items[0].GetString()!, timestamp, duration, maxDuration); + parsed = new LatencyLatestEntry(eventName, timestamp, duration, maxDuration); return true; } } diff --git a/src/StackExchange.Redis/BufferedStreamWriter.Async.cs b/src/StackExchange.Redis/BufferedStreamWriter.Async.cs new file mode 100644 index 000000000..3c2cebee7 --- /dev/null +++ b/src/StackExchange.Redis/BufferedStreamWriter.Async.cs @@ -0,0 +1,132 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Threading.Tasks.Sources; + +namespace StackExchange.Redis; + +internal sealed class BufferedAsyncStreamWriter : CycleBufferStreamWriter, IValueTaskSource +{ + private ManualResetValueTaskSourceCore _readerTask; + + public BufferedAsyncStreamWriter(Stream target, CancellationToken cancellationToken = default) + : base(target, cancellationToken) + { + WriteComplete = Task.Run(CopyOutAsync, cancellationToken); + _readerTask.RunContinuationsAsynchronously = true; // we never want the flusher to take over the copying + } + + public override Task WriteComplete { get; } + + private async Task CopyOutAsync() + { + try + { + while (true) + { + ValueTask pending = AwaitWake(); + if (!pending.IsCompleted) + { + lock (this) + { + // double-checked marking inactive + if (!pending.IsCompleted) OnWriterInactive(); // update state flags + } + } + // await activation and check status; + await pending.ConfigureAwait(false); + + StateFlags stateFlags; + while (true) + { + ReadOnlyMemory memory; + lock (this) + { + stateFlags = State; + var minBytes = (stateFlags & StateFlags.Flush) == 0 ? -1 : 1; + if (!GetFirstChunkInsideLock(minBytes, out memory)) + { + // out of data; remove flush flag and wait for more work + stateFlags &= ~StateFlags.Flush; + break; + } + } + + if (IsFaulted) ThrowCompleteOrFaulted(); // this is cheap to check ongoing + if (!memory.IsEmpty) + { + OnWritten(memory.Length); + OnDebugBufferLog(memory); + + await Target.WriteAsync(memory, CancellationToken).ConfigureAwait(false); + } + + lock (this) + { + DiscardCommitted(memory.Length); + } + } + await Target.FlushAsync(CancellationToken).ConfigureAwait(false); + + if ((stateFlags & StateFlags.Closed) != 0) break; + } + + // recycle on clean exit (only), since we know the buffers aren't being used + lock (this) + { + ReleaseBuffer(); + } + } + catch (Exception ex) + { + Complete(ex); + } + // note we do *not* close the stream here - we have to settle for flushing; Close is explicit + } + + private ValueTask AwaitWake() + { + lock (this) // guard all transitions + { + return new(this, _readerTask.Version); + } + } + + void IValueTaskSource.GetResult(short token) + { + lock (this) // guard all transitions + { + _readerTask.GetResult(token); // may throw, note + _readerTask.Reset(); + } + } + + ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) + { + lock (this) // guard all transitions + { + return _readerTask.GetStatus(token); + } + } + + void IValueTaskSource.OnCompleted( + Action continuation, + object? state, + short token, + ValueTaskSourceOnCompletedFlags flags) + { + lock (this) // guard all transitions + { + _readerTask.OnCompleted(continuation, state, token, flags); + } + } + + protected override void OnWakeReader() + { + lock (this) // guard all transitions + { + _readerTask.SetResult(true); + } + } +} diff --git a/src/StackExchange.Redis/BufferedStreamWriter.Pipe.cs b/src/StackExchange.Redis/BufferedStreamWriter.Pipe.cs new file mode 100644 index 000000000..df6d43a8e --- /dev/null +++ b/src/StackExchange.Redis/BufferedStreamWriter.Pipe.cs @@ -0,0 +1,53 @@ +using System; +using System.IO; +using System.IO.Pipelines; +using System.Threading; +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +internal sealed class PipeStreamWriter : BufferedStreamWriter +{ + private readonly PipeWriter _writer; + + public PipeStreamWriter(Stream target, CancellationToken cancellationToken = default) + : base(target, cancellationToken) + { + var pipe = new Pipe(); + WriteComplete = pipe.Reader.CopyToAsync(Target, cancellationToken); + _writer = pipe.Writer; + } + + public override Task WriteComplete { get; } + + private long _nonFlushed; + public override void Advance(int count) + { + _nonFlushed += count; + _writer.Advance(count); + } + + public override void Flush() + { + var tmp = _nonFlushed; + _nonFlushed = 0; + OnWritten(tmp); + var pending = _writer.FlushAsync(); + if (pending.IsCompleted) + { + pending.GetAwaiter().GetResult(); + } + else + { + // this is bad, but: this type is a temporary kludge while I fix a bug; + // this only happens during back-pressure events, which should be rare + pending.AsTask().Wait(CancellationToken); + } + } + + public override Memory GetMemory(int sizeHint = 0) => _writer.GetMemory(sizeHint); + + public override Span GetSpan(int sizeHint = 0) => _writer.GetSpan(sizeHint); + + public override void Complete(Exception? exception = null) => _writer.Complete(exception); +} diff --git a/src/StackExchange.Redis/BufferedStreamWriter.Sync.cs b/src/StackExchange.Redis/BufferedStreamWriter.Sync.cs new file mode 100644 index 000000000..e5326f43e --- /dev/null +++ b/src/StackExchange.Redis/BufferedStreamWriter.Sync.cs @@ -0,0 +1,111 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +internal sealed class BufferedSyncStreamWriter : CycleBufferStreamWriter +{ + public override bool IsSync => true; + + private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public BufferedSyncStreamWriter(Stream target, CancellationToken cancellationToken = default) + : base(target, cancellationToken) + { + Thread thread = new Thread(static s => ((BufferedSyncStreamWriter)s!).CopyOutSync()) + { + IsBackground = true, + Priority = ThreadPriority.AboveNormal, + Name = "SE.Redis Sync Writer", + }; + thread.Start(this); + } + + public override Task WriteComplete => _completion.Task; + + private bool _signalled; + private void CopyOutSync() + { + try + { + while (true) + { + CancellationToken.ThrowIfCancellationRequested(); + lock (this) + { + if (!_signalled) + { + OnWriterInactive(); + // even if not pulsed, wake periodically to check for hard exit + Monitor.Wait(this, 10_000); + CancellationToken.ThrowIfCancellationRequested(); + } + _signalled = false; + } + + StateFlags stateFlags; + while (true) + { + ReadOnlyMemory memory; + lock (this) + { + stateFlags = State; + var minBytes = (stateFlags & StateFlags.Flush) == 0 ? -1 : 1; + if (!GetFirstChunkInsideLock(minBytes, out memory)) + { + // out of data; remove flush flag and wait for more work + stateFlags &= ~StateFlags.Flush; + break; + } + } + + if (IsFaulted) ThrowCompleteOrFaulted(); // this is cheap to check ongoing + if (!memory.IsEmpty) + { + OnWritten(memory.Length); + OnDebugBufferLog(memory); + +#if NET || NETSTANDARD2_1_OR_GREATER + Target.Write(memory.Span); +#else + Target.Write(memory); +#endif + } + + lock (this) + { + DiscardCommitted(memory.Length); + } + } + + Target.Flush(); + + if ((stateFlags & StateFlags.Closed) != 0) break; + } + + // recycle on clean exit (only), since we know the buffers aren't being used + lock (this) + { + ReleaseBuffer(); + } + _completion.TrySetResult(true); + } + catch (Exception ex) + { + Complete(ex); + _completion.TrySetException(ex); + } + // note we do *not* close the stream here - we have to settle for flushing; Close is explicit + } + + protected override void OnWakeReader() + { + lock (this) + { + _signalled = true; + Monitor.Pulse(this); + } + } +} diff --git a/src/StackExchange.Redis/BufferedStreamWriter.cs b/src/StackExchange.Redis/BufferedStreamWriter.cs new file mode 100644 index 000000000..442c5ed1f --- /dev/null +++ b/src/StackExchange.Redis/BufferedStreamWriter.cs @@ -0,0 +1,265 @@ +using System; +using System.Buffers; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using RESPite.Buffers; + +namespace StackExchange.Redis; + +internal abstract class BufferedStreamWriter(Stream target, CancellationToken cancellationToken) + : IBufferWriter, IDisposable, IAsyncDisposable +{ + /* What is this? + * + * Basically, an abstraction similar to Pipe - it has a separate write and read head, etc, but + * the key difference is that it is focused on reducing context switches: + * + * - explicit flush is *synchronous*, simply marking the tail buffer as "ready to read"; this is actually pretty + * similar to Pipe if we ignore back-pressure, which it kinda does by default + * - implicit flush is implicit - i.e. when committed work fills a page, that page is flushed automatically + * - the consumption API allows fully synchronous consumption, if desired + * + * At the moment, 3 concrete implementations are provided: + * - BufferedAsyncStreamWriter: uses the thread-pool and async I/O to copy data to the target stream + * - BufferedSyncStreamWriter: uses a dedicated thread to copy data to the target stream using sync IO + * - PipeStreamWriter: uses the pre-existing Pipe API and pre-built PipeWriter.CopyTo(Stream) + * + * The intention is that: + * - pub/sub always uses BufferedAsyncStreamWriter + * - interactive uses BufferedSyncStreamWriter in low-connection-count scenarios, + * and BufferedAsyncStreamWriter in high-connection-count scenarios (there's some missing work here in + * tracking the count and transitioning from sync to async as we cross some threshold) + * + * So why the Pipe version? and why not *just* use Pipe? The custom sync/async versions out-perform Pipe, but + * there's a dangling bug where occasionally it will stall - presumably some edge-case where the wake logic + * is borked. When I debug that, I'll make the other versions the defaults, but for now: Pipe is the default, + * with the others only available to me for testing. + */ + + public enum WriteMode + { + Default, + Sync, + Async, + Pipe, + } + + public virtual bool IsSync => false; + + public static BufferedStreamWriter Create(WriteMode mode, ConnectionType connectionType, Stream target, CancellationToken cancellationToken) + { + // TODO: change to Async when debugged + const WriteMode DefaultAsyncMode = WriteMode.Pipe; + + if (connectionType is ConnectionType.Subscription) + { + // sync-mode targets latency; pub/sub doens't need that + mode = DefaultAsyncMode; + } + else if (mode is WriteMode.Default) + { + mode = DefaultAsyncMode; + } + return mode switch + { + WriteMode.Sync => new BufferedSyncStreamWriter(target, cancellationToken), + WriteMode.Async => new BufferedAsyncStreamWriter(target, cancellationToken), + WriteMode.Pipe => new PipeStreamWriter(target, cancellationToken), + _ => throw new ArgumentOutOfRangeException(nameof(mode)), + }; + } + + // ReSharper disable once ReplaceWithFieldKeyword + private readonly CancellationToken _cancellationToken = cancellationToken; + protected ref readonly CancellationToken CancellationToken => ref _cancellationToken; + protected Stream Target { get; } = target; + + public abstract Task WriteComplete { get; } + + protected void OnWritten(long count) => _totalBytesWritten += count; + protected void OnWritten(int count) => _totalBytesWritten += count; + private long _totalBytesWritten; + public long TotalBytesWritten => _totalBytesWritten; + + public abstract void Complete(Exception? exception = null); + public void Dispose() => Target.Dispose(); + + public ValueTask DisposeAsync() + { + if (Target is IAsyncDisposable asyncDisposable) + { + return asyncDisposable.DisposeAsync(); + } + Target.Dispose(); + return default; + } + + public abstract void Advance(int count); + + public abstract Memory GetMemory(int sizeHint = 0); + + public abstract Span GetSpan(int sizeHint = 0); + + [Conditional("DEBUG")] + public virtual void DebugSetLog(Action log) { } + + public abstract void Flush(); +} + +internal abstract class CycleBufferStreamWriter : BufferedStreamWriter, ICycleBufferCallback +{ + protected CycleBufferStreamWriter(Stream target, CancellationToken cancellationToken) + : base(target, cancellationToken) + { + _buffer = CycleBuffer.Create(callback: this); + } + + private CycleBuffer _buffer; + private StateFlags _stateFlags; + private Exception? _exception; + + protected bool IsFaulted => _exception is not null; + + [Flags] + protected enum StateFlags + { + None = 0, + Flush = 1 << 0, // allow reading incomplete pages + ActiveWriter = 1 << 1, + Closed = 1 << 2, + } + + protected bool GetFirstChunkInsideLock(int minBytes, out ReadOnlyMemory memory) + => _buffer.TryGetFirstCommittedMemory(minBytes, out memory); + + protected void ReleaseBuffer() => _buffer.Release(); + + protected void DiscardCommitted(int count) => _buffer.DiscardCommitted(count); + + /// + /// Activate the writer if necessary, but only consume complete pages. + /// + void ICycleBufferCallback.PageComplete() => OnActivate(StateFlags.None); + + /// + /// Activate the writer if necessary, and indicate that all committed data can be consumed, even incomplete pages. + /// + public override void Flush() => OnActivate(StateFlags.Flush); + + public override void Complete(Exception? exception = null) + { + _exception ??= exception; + OnActivate(StateFlags.Flush | StateFlags.Closed); + } + + private void OnActivate(StateFlags newFlags) + { + bool activate = false; + lock (this) + { + var state = _stateFlags; + if ((state & StateFlags.Closed) != 0) return; + state |= newFlags & ~StateFlags.ActiveWriter; + if ((state & StateFlags.ActiveWriter) == 0) + { + state |= StateFlags.ActiveWriter; + activate = true; + } + _stateFlags = state; + } + + if (activate) OnWakeReader(); + } + + protected abstract void OnWakeReader(); + + [Conditional("DEBUG")] + protected void OnDebugBufferLog(ReadOnlyMemory memory) + { +#if DEBUG + if (_log is not null) + { + const string CR = "\u240D", LF = "\u240A", CRLF = CR + LF; + string raw = Encoding.UTF8.GetString(memory.Span) + .Replace("\r\n", CRLF).Replace("\r", CR).Replace("\n", LF); + string s = $"{id}.{fragment++}: {raw}"; + OnDebugLog(s); + } +#endif + } + + public override void Advance(int count) + { + ThrowIfComplete(); + lock (this) + { + _buffer.Commit(count); + } + } + + public override Memory GetMemory(int sizeHint = 0) + { + ThrowIfComplete(); + lock (this) + { + return _buffer.GetUncommittedMemory(sizeHint); + } + } + + public override Span GetSpan(int sizeHint = 0) + { + ThrowIfComplete(); + lock (this) + { + return _buffer.GetUncommittedSpan(sizeHint); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected void ThrowIfComplete() + { + // prevents a writer continuing to write to a dead pipe + if ((_stateFlags & StateFlags.Closed) != 0) ThrowCompleteOrFaulted(); + } + + [MethodImpl(MethodImplOptions.NoInlining), DoesNotReturn] + protected void ThrowCompleteOrFaulted() + { + var ex = _exception; + if (ex is null) throw new InvalidOperationException("Output has been completed successfully."); + throw new InvalidOperationException($"Output has been completed with fault: " + ex.Message, ex); + } + + protected StateFlags State => _stateFlags; + protected void OnWriterInactive() => _stateFlags &= ~StateFlags.ActiveWriter; + +#if DEBUG + private readonly int id = Interlocked.Increment(ref s_id); + private int fragment; + private static int s_id; +#endif + + [Conditional("DEBUG")] + protected void OnDebugLog(string message) + { +#if DEBUG + // deliberately get away from the working thread + ThreadPool.QueueUserWorkItem(_ => _log?.Invoke(message)); +#endif + } + + public override void DebugSetLog(Action log) + { +#if DEBUG + _log = log; +#endif + } +#if DEBUG + private Action? _log; +#endif +} diff --git a/src/StackExchange.Redis/ChannelMessageQueue.cs b/src/StackExchange.Redis/ChannelMessageQueue.cs index f7bd9a4a2..7defc35cc 100644 --- a/src/StackExchange.Redis/ChannelMessageQueue.cs +++ b/src/StackExchange.Redis/ChannelMessageQueue.cs @@ -1,5 +1,4 @@ using System; -using System.Buffers.Text; using System.Collections.Generic; using System.Threading; using System.Threading.Channels; @@ -256,7 +255,7 @@ private async Task OnMessageAsyncImpl() try { var task = handler?.Invoke(next); - if (task != null && task.Status != TaskStatus.RanToCompletion) await task.ForAwait(); + if (task != null && !task.IsCompletedSuccessfully) await task.ForAwait(); } catch { } // matches MessageCompletable } diff --git a/src/StackExchange.Redis/ClientInfo.cs b/src/StackExchange.Redis/ClientInfo.cs index d743affff..511aee723 100644 --- a/src/StackExchange.Redis/ClientInfo.cs +++ b/src/StackExchange.Redis/ClientInfo.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Net; +using RESPite.Messages; namespace StackExchange.Redis { @@ -289,18 +290,16 @@ private static void AddFlag(ref ClientFlags value, string raw, ClientFlags toAdd private sealed class ClientInfoProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + if (reader.Prefix is RespPrefix.BulkString or RespPrefix.VerbatimString) { - case ResultType.BulkString: - var raw = result.GetString(); - if (TryParse(raw, out var clients)) - { - SetResult(message, clients); - return true; - } - break; + var raw = reader.ReadString(); + if (TryParse(raw, out var clients)) + { + SetResult(message, clients); + return true; + } } return false; } diff --git a/src/StackExchange.Redis/CommandBytes.cs b/src/StackExchange.Redis/CommandBytes.cs deleted file mode 100644 index 19a69549b..000000000 --- a/src/StackExchange.Redis/CommandBytes.cs +++ /dev/null @@ -1,207 +0,0 @@ -using System; -using System.Buffers; -using System.Text; - -namespace StackExchange.Redis -{ - internal readonly struct CommandBytes : IEquatable - { - private static Encoding Encoding => Encoding.UTF8; - - internal static unsafe CommandBytes TrimToFit(string value) - { - if (string.IsNullOrWhiteSpace(value)) return default; - value = value.Trim(); - var len = Encoding.GetByteCount(value); - if (len <= MaxLength) return new CommandBytes(value); // all fits - - fixed (char* c = value) - { - byte* b = stackalloc byte[ChunkLength * sizeof(ulong)]; - var encoder = PhysicalConnection.GetPerThreadEncoder(); - encoder.Convert(c, value.Length, b, MaxLength, true, out var maxLen, out _, out var isComplete); - if (!isComplete) maxLen--; - return new CommandBytes(value.Substring(0, maxLen)); - } - } - - // Uses [n=4] x UInt64 values to store a command payload, - // allowing allocation free storage and efficient - // equality tests. If you're glancing at this and thinking - // "that's what fixed buffers are for", please see: - // https://github.com/dotnet/coreclr/issues/19149 - // - // note: this tries to use case insensitive comparison - private readonly ulong _0, _1, _2, _3; - private const int ChunkLength = 4; // must reflect qty above - - public const int MaxLength = (ChunkLength * sizeof(ulong)) - 1; - - public override int GetHashCode() - { - var hashCode = -1923861349; - hashCode = (hashCode * -1521134295) + _0.GetHashCode(); - hashCode = (hashCode * -1521134295) + _1.GetHashCode(); - hashCode = (hashCode * -1521134295) + _2.GetHashCode(); - hashCode = (hashCode * -1521134295) + _3.GetHashCode(); - return hashCode; - } - - public override bool Equals(object? obj) => obj is CommandBytes cb && Equals(cb); - - bool IEquatable.Equals(CommandBytes other) => _0 == other._0 && _1 == other._1 && _2 == other._2 && _3 == other._3; - - public bool Equals(in CommandBytes other) => _0 == other._0 && _1 == other._1 && _2 == other._2 && _3 == other._3; - - // note: don't add == operators; with the implicit op above, that invalidates "==null" compiler checks (which should report a failure!) - public static implicit operator CommandBytes(string value) => new CommandBytes(value); - - public override unsafe string ToString() - { - fixed (ulong* uPtr = &_0) - { - var bPtr = (byte*)uPtr; - int len = *bPtr; - return len == 0 ? "" : Encoding.GetString(bPtr + 1, *bPtr); - } - } - public unsafe int Length - { - get - { - fixed (ulong* uPtr = &_0) - { - return *(byte*)uPtr; - } - } - } - - public bool IsEmpty => _0 == 0L; // cheap way of checking zero length - - public unsafe void CopyTo(Span target) - { - fixed (ulong* uPtr = &_0) - { - byte* bPtr = (byte*)uPtr; - new Span(bPtr + 1, *bPtr).CopyTo(target); - } - } - - public unsafe byte this[int index] - { - get - { - fixed (ulong* uPtr = &_0) - { - byte* bPtr = (byte*)uPtr; - int len = *bPtr; - if (index < 0 || index >= len) throw new IndexOutOfRangeException(); - return bPtr[index + 1]; - } - } - } - - public unsafe CommandBytes(string? value) - { - _0 = _1 = _2 = _3 = 0L; - if (value.IsNullOrEmpty()) return; - - var len = Encoding.GetByteCount(value); - if (len > MaxLength) throw new ArgumentOutOfRangeException($"Command '{value}' exceeds library limit of {MaxLength} bytes"); - fixed (ulong* uPtr = &_0) - { - byte* bPtr = (byte*)uPtr; - fixed (char* cPtr = value) - { - len = Encoding.GetBytes(cPtr, value.Length, bPtr + 1, MaxLength); - } - *bPtr = (byte)UpperCasify(len, bPtr + 1); - } - } - - public unsafe CommandBytes(ReadOnlySpan value) - { - if (value.Length > MaxLength) throw new ArgumentOutOfRangeException("Maximum command length exceeded: " + value.Length + " bytes"); - _0 = _1 = _2 = _3 = 0L; - fixed (ulong* uPtr = &_0) - { - byte* bPtr = (byte*)uPtr; - value.CopyTo(new Span(bPtr + 1, value.Length)); - *bPtr = (byte)UpperCasify(value.Length, bPtr + 1); - } - } - - public unsafe CommandBytes(in ReadOnlySequence value) - { - if (value.Length > MaxLength) throw new ArgumentOutOfRangeException(nameof(value), "Maximum command length exceeded"); - int len = unchecked((int)value.Length); - _0 = _1 = _2 = _3 = 0L; - fixed (ulong* uPtr = &_0) - { - byte* bPtr = (byte*)uPtr; - var target = new Span(bPtr + 1, len); - - if (value.IsSingleSegment) - { - value.First.Span.CopyTo(target); - } - else - { - foreach (var segment in value) - { - segment.Span.CopyTo(target); - target = target.Slice(segment.Length); - } - } - *bPtr = (byte)UpperCasify(len, bPtr + 1); - } - } - private unsafe int UpperCasify(int len, byte* bPtr) - { - const ulong HighBits = 0x8080808080808080; - if (((_0 | _1 | _2 | _3) & HighBits) == 0) - { - // no Unicode; use ASCII bit bricks - for (int i = 0; i < len; i++) - { - *bPtr = ToUpperInvariantAscii(*bPtr++); - } - return len; - } - else - { - return UpperCasifyUnicode(len, bPtr); - } - } - - private static unsafe int UpperCasifyUnicode(int oldLen, byte* bPtr) - { - const int MaxChars = ChunkLength * sizeof(ulong); // leave rounded up; helps stackalloc - char* workspace = stackalloc char[MaxChars]; - int charCount = Encoding.GetChars(bPtr, oldLen, workspace, MaxChars); - char* c = workspace; - for (int i = 0; i < charCount; i++) - { - *c = char.ToUpperInvariant(*c++); - } - int newLen = Encoding.GetBytes(workspace, charCount, bPtr, MaxLength); - // don't forget to zero any shrink - for (int i = newLen; i < oldLen; i++) - { - bPtr[i] = 0; - } - return newLen; - } - - private static byte ToUpperInvariantAscii(byte b) => b >= 'a' && b <= 'z' ? (byte)(b - 32) : b; - - internal unsafe byte[] ToArray() - { - fixed (ulong* uPtr = &_0) - { - byte* bPtr = (byte*)uPtr; - return new Span(bPtr + 1, *bPtr).ToArray(); - } - } - } -} diff --git a/src/StackExchange.Redis/CommandMap.cs b/src/StackExchange.Redis/CommandMap.cs index 683e51219..9a8817a02 100644 --- a/src/StackExchange.Redis/CommandMap.cs +++ b/src/StackExchange.Redis/CommandMap.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Text; +using RESPite; namespace StackExchange.Redis { @@ -9,9 +10,9 @@ namespace StackExchange.Redis /// public sealed class CommandMap { - private readonly CommandBytes[] map; + private readonly AsciiHash[] map; - internal CommandMap(CommandBytes[] map) => this.map = map; + internal CommandMap(AsciiHash[] map) => this.map = map; /// /// The default commands specified by redis. @@ -132,7 +133,7 @@ public static CommandMap Create(HashSet commands, bool available = true) { var dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); // nix everything - foreach (RedisCommand command in (RedisCommand[])Enum.GetValues(typeof(RedisCommand))) + foreach (RedisCommand command in AllCommands) { dictionary[command.ToString()] = null; } @@ -154,7 +155,7 @@ public static CommandMap Create(HashSet commands, bool available = true) // nix the things that are specified foreach (var command in commands) { - if (Enum.TryParse(command, true, out RedisCommand parsed)) + if (RedisCommandMetadata.TryParseCI(command, out RedisCommand parsed)) { (exclusions ??= new HashSet()).Add(parsed); } @@ -177,10 +178,13 @@ public override string ToString() internal void AppendDeltas(StringBuilder sb) { + var all = AllCommands; for (int i = 0; i < map.Length; i++) { - var keyString = ((RedisCommand)i).ToString(); - var keyBytes = new CommandBytes(keyString); + var knownCmd = all[i]; + if (knownCmd is RedisCommand.UNKNOWN) continue; + var keyString = knownCmd.ToString(); + var keyBytes = new AsciiHash(keyString); var value = map[i]; if (!keyBytes.Equals(value)) { @@ -195,32 +199,26 @@ internal void AssertAvailable(RedisCommand command) if (map[(int)command].IsEmpty) throw ExceptionFactory.CommandDisabled(command); } - internal CommandBytes GetBytes(RedisCommand command) => map[(int)command]; - - internal CommandBytes GetBytes(string command) - { - if (command == null) return default; - if (Enum.TryParse(command, true, out RedisCommand cmd)) - { - // we know that one! - return map[(int)cmd]; - } - return new CommandBytes(command); - } + internal AsciiHash GetBytes(RedisCommand command) => map[(int)command]; internal bool IsAvailable(RedisCommand command) => !map[(int)command].IsEmpty; + private static RedisCommand[]? s_AllCommands; + + private static ReadOnlySpan AllCommands => s_AllCommands ??= (RedisCommand[])Enum.GetValues(typeof(RedisCommand)); + private static CommandMap CreateImpl(Dictionary? caseInsensitiveOverrides, HashSet? exclusions) { - var commands = (RedisCommand[])Enum.GetValues(typeof(RedisCommand)); + var commands = AllCommands; - var map = new CommandBytes[commands.Length]; + // todo: optimize and support ad-hoc overrides/disables, and shared buffer rather than multiple arrays + var map = new AsciiHash[commands.Length]; for (int i = 0; i < commands.Length; i++) { int idx = (int)commands[i]; string? name = commands[i].ToString(), value = name; - if (exclusions?.Contains(commands[i]) == true) + if (commands[i] is RedisCommand.UNKNOWN || exclusions?.Contains(commands[i]) == true) { map[idx] = default; } @@ -228,9 +226,9 @@ private static CommandMap CreateImpl(Dictionary? caseInsensitiv { if (caseInsensitiveOverrides != null && caseInsensitiveOverrides.TryGetValue(name, out string? tmp)) { - value = tmp; + value = tmp?.ToUpperInvariant(); } - map[idx] = new CommandBytes(value); + map[idx] = new AsciiHash(value); } } return new CommandMap(map); diff --git a/src/StackExchange.Redis/CommandTrace.cs b/src/StackExchange.Redis/CommandTrace.cs index a61499f0c..7125bc7b3 100644 --- a/src/StackExchange.Redis/CommandTrace.cs +++ b/src/StackExchange.Redis/CommandTrace.cs @@ -1,4 +1,5 @@ using System; +using RESPite.Messages; namespace StackExchange.Redis { @@ -71,25 +72,37 @@ internal CommandTrace(long uniqueId, long time, long duration, RedisValue[] argu private sealed class CommandTraceProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeArray) + // see: SLOWLOG GET + if (reader.IsAggregate) { - case ResultType.Array: - var parts = result.GetItems(); - CommandTrace[] arr = new CommandTrace[parts.Length]; - int i = 0; - foreach (var item in parts) + var arr = reader.ReadPastArray(ParseOne, scalar: false)!; + if (arr.AnyNull()) return false; + + SetResult(message, arr); + return true; + } + + return false; + + static CommandTrace ParseOne(ref RespReader reader) + { + CommandTrace result = null!; + if (reader.IsAggregate) + { + long uniqueId = 0, time = 0, duration = 0; + if (reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out uniqueId) + && reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out time) + && reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out duration) + && reader.TryMoveNext() && reader.IsAggregate) { - var subParts = item.GetItems(); - if (!subParts[0].TryGetInt64(out long uniqueid) || !subParts[1].TryGetInt64(out long time) || !subParts[2].TryGetInt64(out long duration)) - return false; - arr[i++] = new CommandTrace(uniqueid, time, duration, subParts[3].GetItemsAsValues()!); + var values = reader.ReadPastRedisValues() ?? []; + result = new CommandTrace(uniqueId, time, duration, values); } - SetResult(message, arr); - return true; + } + return result; } - return false; } } } diff --git a/src/StackExchange.Redis/Condition.cs b/src/StackExchange.Redis/Condition.cs index ec7ee53b6..fa7e76299 100644 --- a/src/StackExchange.Redis/Condition.cs +++ b/src/StackExchange.Redis/Condition.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using RESPite.Messages; namespace StackExchange.Redis { @@ -372,7 +373,8 @@ public static Condition StringNotEqual(RedisKey key, RedisValue value) internal abstract IEnumerable CreateMessages(int db, IResultBox? resultBox); internal abstract int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy); - internal abstract bool TryValidate(in RawResult result, out bool value); + + internal abstract bool TryValidate(ref RespReader reader, out bool value); internal sealed class ConditionProcessor : ResultProcessor { @@ -387,13 +389,12 @@ public static Message CreateMessage(Condition condition, int db, CommandFlags fl public static Message CreateMessage(Condition condition, int db, CommandFlags flags, RedisCommand command, in RedisKey key, in RedisValue value, in RedisValue value1, in RedisValue value2, in RedisValue value3, in RedisValue value4) => new ConditionMessage(condition, db, flags, command, key, value, value1, value2, value3, value4); - [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0071:Simplify interpolation", Justification = "Allocations (string.Concat vs. string.Format)")] - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - connection?.BridgeCouldBeNull?.Multiplexer?.OnTransactionLog($"condition '{message.CommandAndKey}' got '{result.ToString()}'"); + connection?.BridgeCouldBeNull?.Multiplexer?.OnTransactionLog($"condition '{message.CommandAndKey}' got '{reader.GetOverview()}'"); var msg = message as ConditionMessage; var condition = msg?.Condition; - if (condition != null && condition.TryValidate(result, out bool final)) + if (condition != null && condition.TryValidate(ref reader, out bool final)) { SetResult(message, final); return true; @@ -432,26 +433,26 @@ public ConditionMessage(Condition condition, int db, CommandFlags flags, RedisCo this.value4 = value4; // note no assert here } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { if (value.IsNull) { - physical.WriteHeader(command, 1); - physical.Write(Key); + writer.WriteHeader(command, 1); + writer.Write(Key); } else { - physical.WriteHeader(command, value1.IsNull ? 2 : value2.IsNull ? 3 : value3.IsNull ? 4 : value4.IsNull ? 5 : 6); - physical.Write(Key); - physical.WriteBulkString(value); + writer.WriteHeader(command, value1.IsNull ? 2 : value2.IsNull ? 3 : value3.IsNull ? 4 : value4.IsNull ? 5 : 6); + writer.Write(Key); + writer.WriteBulkString(value); if (!value1.IsNull) - physical.WriteBulkString(value1); + writer.WriteBulkString(value1); if (!value2.IsNull) - physical.WriteBulkString(value2); + writer.WriteBulkString(value2); if (!value3.IsNull) - physical.WriteBulkString(value3); + writer.WriteBulkString(value3); if (!value4.IsNull) - physical.WriteBulkString(value4); + writer.WriteBulkString(value4); } } public override int ArgCount => value.IsNull ? 1 : value1.IsNull ? 2 : value2.IsNull ? 3 : value3.IsNull ? 4 : value4.IsNull ? 5 : 6; @@ -510,19 +511,20 @@ internal override IEnumerable CreateMessages(int db, IResultBox? result internal override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) => serverSelectionStrategy.HashSlot(key); - internal override bool TryValidate(in RawResult result, out bool value) + internal override bool TryValidate(ref RespReader reader, out bool value) { switch (type) { case RedisType.SortedSet: - var parsedValue = result.AsRedisValue(); - value = parsedValue.IsNull != expectedResult; - ConnectionMultiplexer.TraceWithoutContext("exists: " + parsedValue + "; expected: " + expectedResult + "; voting: " + value); + // ZSCORE returns bulk string (score) or null + var parsedValue = reader.IsNull; + value = parsedValue != expectedResult; + ConnectionMultiplexer.TraceWithoutContext("exists: " + !parsedValue + "; expected: " + expectedResult + "; voting: " + value); return true; default: - bool parsed; - if (ResultProcessor.DemandZeroOrOneProcessor.TryGet(result, out parsed)) + // EXISTS, HEXISTS, SISMEMBER return integer 0 or 1 + if (ResultProcessor.DemandZeroOrOneProcessor.TryGet(ref reader, out bool parsed)) { value = parsed == expectedResult; ConnectionMultiplexer.TraceWithoutContext("exists: " + parsed + "; expected: " + expectedResult + "; voting: " + value); @@ -586,12 +588,30 @@ internal override IEnumerable CreateMessages(int db, IResultBox? result internal override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) => serverSelectionStrategy.HashSlot(key); - internal override bool TryValidate(in RawResult result, out bool value) + internal override bool TryValidate(ref RespReader reader, out bool value) { - value = result.ItemsCount == 1 && result[0].AsRedisValue().StartsWith(prefix); - - if (!expectedResult) value = !value; - return true; + // ZRANGEBYLEX returns an array with 0 or 1 elements + if (reader.IsAggregate) + { + var count = reader.AggregateLength(); + if (count == 1) + { + // Check if the first element starts with prefix + if (reader.TryMoveNext() && reader.IsScalar) + { + value = reader.ReadRedisValue().StartsWith(prefix); + if (!expectedResult) value = !value; + return true; + } + } + else if (count == 0) + { + value = !expectedResult; // No match found + return true; + } + } + value = false; + return false; } } @@ -640,13 +660,21 @@ internal override IEnumerable CreateMessages(int db, IResultBox? result internal override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) => serverSelectionStrategy.HashSlot(key); - internal override bool TryValidate(in RawResult result, out bool value) + internal override bool TryValidate(ref RespReader reader, out bool value) { + // All commands (ZSCORE, GET, HGET) return scalar values + if (!reader.IsScalar) + { + value = false; + return false; + } + switch (type) { case RedisType.SortedSet: + // ZSCORE returns bulk string (score as double) or null var parsedValue = RedisValue.Null; - if (!result.IsNull && result.TryGetDouble(out var val)) + if (!reader.IsNull && reader.TryReadDouble(out var val)) { parsedValue = val; } @@ -657,19 +685,12 @@ internal override bool TryValidate(in RawResult result, out bool value) return true; default: - switch (result.Resp2TypeBulkString) - { - case ResultType.BulkString: - case ResultType.SimpleString: - case ResultType.Integer: - var parsed = result.AsRedisValue(); - value = (parsed == expectedValue) == expectedEqual; - ConnectionMultiplexer.TraceWithoutContext("actual: " + (string?)parsed + "; expected: " + (string?)expectedValue + - "; wanted: " + (expectedEqual ? "==" : "!=") + "; voting: " + value); - return true; - } - value = false; - return false; + // GET or HGET returns bulk string, simple string, or integer + var parsed = reader.ReadRedisValue(); + value = (parsed == expectedValue) == expectedEqual; + ConnectionMultiplexer.TraceWithoutContext("actual: " + (string?)parsed + "; expected: " + (string?)expectedValue + + "; wanted: " + (expectedEqual ? "==" : "!=") + "; voting: " + value); + return true; } } } @@ -711,26 +732,24 @@ internal override IEnumerable CreateMessages(int db, IResultBox? result internal override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) => serverSelectionStrategy.HashSlot(key); - internal override bool TryValidate(in RawResult result, out bool value) + internal override bool TryValidate(ref RespReader reader, out bool value) { - switch (result.Resp2TypeBulkString) + // LINDEX returns bulk string, simple string, or integer + if (reader.IsScalar) { - case ResultType.BulkString: - case ResultType.SimpleString: - case ResultType.Integer: - var parsed = result.AsRedisValue(); - if (expectedValue.HasValue) - { - value = (parsed == expectedValue.Value) == expectedResult; - ConnectionMultiplexer.TraceWithoutContext("actual: " + (string?)parsed + "; expected: " + (string?)expectedValue.Value + - "; wanted: " + (expectedResult ? "==" : "!=") + "; voting: " + value); - } - else - { - value = parsed.IsNull != expectedResult; - ConnectionMultiplexer.TraceWithoutContext("exists: " + parsed + "; expected: " + expectedResult + "; voting: " + value); - } - return true; + var parsed = reader.ReadRedisValue(); + if (expectedValue.HasValue) + { + value = (parsed == expectedValue.Value) == expectedResult; + ConnectionMultiplexer.TraceWithoutContext("actual: " + (string?)parsed + "; expected: " + (string?)expectedValue.Value + + "; wanted: " + (expectedResult ? "==" : "!=") + "; voting: " + value); + } + else + { + value = parsed.IsNull != expectedResult; + ConnectionMultiplexer.TraceWithoutContext("exists: " + parsed + "; expected: " + expectedResult + "; voting: " + value); + } + return true; } value = false; return false; @@ -784,18 +803,15 @@ internal override IEnumerable CreateMessages(int db, IResultBox? result internal override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) => serverSelectionStrategy.HashSlot(key); - internal override bool TryValidate(in RawResult result, out bool value) + internal override bool TryValidate(ref RespReader reader, out bool value) { - switch (result.Resp2TypeBulkString) + // Length commands (HLEN, SCARD, LLEN, ZCARD, XLEN, STRLEN) return integer + if (reader.IsScalar && reader.TryReadInt64(out var parsed)) { - case ResultType.BulkString: - case ResultType.SimpleString: - case ResultType.Integer: - var parsed = result.AsRedisValue(); - value = parsed.IsInteger && (expectedLength.CompareTo((long)parsed) == compareToResult); - ConnectionMultiplexer.TraceWithoutContext("actual: " + (string?)parsed + "; expected: " + expectedLength + - "; wanted: " + GetComparisonString() + "; voting: " + value); - return true; + value = expectedLength.CompareTo(parsed) == compareToResult; + ConnectionMultiplexer.TraceWithoutContext("actual: " + parsed + "; expected: " + expectedLength + + "; wanted: " + GetComparisonString() + "; voting: " + value); + return true; } value = false; return false; @@ -841,18 +857,16 @@ internal override IEnumerable CreateMessages(int db, IResultBox? result internal override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) => serverSelectionStrategy.HashSlot(key); - internal override bool TryValidate(in RawResult result, out bool value) + internal override bool TryValidate(ref RespReader reader, out bool value) { - switch (result.Resp2TypeBulkString) + // Length commands (HLEN, SCARD, LLEN, ZCARD, XLEN, STRLEN) return integer + if (reader.IsScalar) { - case ResultType.BulkString: - case ResultType.SimpleString: - case ResultType.Integer: - var parsed = result.AsRedisValue(); - value = parsed.IsInteger && (expectedLength.CompareTo((long)parsed) == compareToResult); - ConnectionMultiplexer.TraceWithoutContext("actual: " + (string?)parsed + "; expected: " + expectedLength + - "; wanted: " + GetComparisonString() + "; voting: " + value); - return true; + var parsed = reader.ReadRedisValue(); + value = parsed.IsInteger && (expectedLength.CompareTo((long)parsed) == compareToResult); + ConnectionMultiplexer.TraceWithoutContext("actual: " + (string?)parsed + "; expected: " + expectedLength + + "; wanted: " + GetComparisonString() + "; voting: " + value); + return true; } value = false; return false; @@ -898,17 +912,16 @@ internal override IEnumerable CreateMessages(int db, IResultBox? result internal override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) => serverSelectionStrategy.HashSlot(key); - internal override bool TryValidate(in RawResult result, out bool value) + internal override bool TryValidate(ref RespReader reader, out bool value) { - switch (result.Resp2TypeBulkString) + // ZCOUNT returns integer + if (reader.IsScalar) { - case ResultType.Integer: - var parsedValue = result.AsRedisValue(); - value = (parsedValue == expectedValue) == expectedEqual; - ConnectionMultiplexer.TraceWithoutContext("actual: " + (string?)parsedValue + "; expected: " + (string?)expectedValue + "; wanted: " + (expectedEqual ? "==" : "!=") + "; voting: " + value); - return true; + var parsedValue = reader.ReadRedisValue(); + value = (parsedValue == expectedValue) == expectedEqual; + ConnectionMultiplexer.TraceWithoutContext("actual: " + (string?)parsedValue + "; expected: " + (string?)expectedValue + "; wanted: " + (expectedEqual ? "==" : "!=") + "; voting: " + value); + return true; } - value = false; return false; } diff --git a/src/StackExchange.Redis/ConfigField.cs b/src/StackExchange.Redis/ConfigField.cs new file mode 100644 index 000000000..b39e1e029 --- /dev/null +++ b/src/StackExchange.Redis/ConfigField.cs @@ -0,0 +1,49 @@ +using System; +using RESPite; + +namespace StackExchange.Redis; + +/// +/// Fields that can appear in a CONFIG GET command response. +/// +internal enum ConfigField +{ + /// + /// Unknown or unrecognized field. + /// + [AsciiHash("")] + Unknown = 0, + + /// + /// Timeout configuration. + /// + [AsciiHash("timeout")] + Timeout, + + /// + /// Number of databases. + /// + [AsciiHash("databases")] + Databases, + + /// + /// Replica read-only setting (slave-read-only). + /// + [AsciiHash("slave-read-only")] + SlaveReadOnly, + + /// + /// Replica read-only setting (replica-read-only). + /// + [AsciiHash("replica-read-only")] + ReplicaReadOnly, +} + +/// +/// Metadata and parsing methods for ConfigField. +/// +internal static partial class ConfigFieldMetadata +{ + [AsciiHash] + internal static partial bool TryParse(ReadOnlySpan value, out ConfigField field); +} diff --git a/src/StackExchange.Redis/Configuration/LoggingTunnel.cs b/src/StackExchange.Redis/Configuration/LoggingTunnel.cs index 18216c1f2..33b6f182b 100644 --- a/src/StackExchange.Redis/Configuration/LoggingTunnel.cs +++ b/src/StackExchange.Redis/Configuration/LoggingTunnel.cs @@ -3,15 +3,15 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.IO.Pipelines; using System.Net; using System.Net.Security; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; -using Pipelines.Sockets.Unofficial; -using Pipelines.Sockets.Unofficial.Arenas; +using RESPite; +using RESPite.Buffers; +using RESPite.Messages; using static StackExchange.Redis.PhysicalConnection; namespace StackExchange.Redis.Configuration; @@ -27,25 +27,158 @@ public abstract class LoggingTunnel : Tunnel private readonly bool _ssl; private readonly Tunnel? _tail; + internal sealed class StreamRespReader(Stream source, bool isInbound) : IDisposable + { + private CycleBuffer _readBuffer = CycleBuffer.Create(); + private RespScanState _state; + private bool _reading, _disposed; // we need to track the state of the reader to avoid releasing the buffer while it's in use + + public long Position { get; private set; } + internal bool TryTakeOne(out ContextualRedisResult result, bool withData = true) + { + var fullBuffer = _readBuffer.GetAllCommitted(); + var newData = fullBuffer.Slice(_state.TotalBytes); + var status = RespFrameScanner.Default.TryRead(ref _state, newData); + switch (status) + { + case OperationStatus.Done: + var frame = fullBuffer.Slice(0, _state.TotalBytes); + var reader = new RespReader(frame); + reader.MovePastBof(); + bool isOutOfBand = reader.Prefix is RespPrefix.Push + || (isInbound && reader.IsAggregate && + !IsArrayOutOfBand(in reader)); + + RedisResult? parsed; + if (withData) + { + if (!RedisResult.TryCreate(null, ref reader, out parsed)) + { + ThrowInvalidReadStatus(OperationStatus.InvalidData); + } + } + else + { + parsed = null; + } + result = new(parsed, isOutOfBand); + Position += _state.TotalBytes; + _readBuffer.DiscardCommitted((int)_state.TotalBytes); + _state = default; + return true; + case OperationStatus.NeedMoreData: + result = default; + return false; + default: + ThrowInvalidReadStatus(status); + goto case OperationStatus.NeedMoreData; // never reached + } + } + + private static bool IsArrayOutOfBand(in RespReader source) + { + var reader = source; + int len; + if (!reader.IsStreaming + && (len = reader.AggregateLength()) >= 2 + && (reader.SafeTryMoveNext() & reader.IsInlineScalar & !reader.IsError)) + { + const int MAX_TYPE_LEN = 16; + var span = reader.TryGetSpan(out var tmp) + ? tmp + : StackCopyLengthChecked(in reader, stackalloc byte[MAX_TYPE_LEN]); + + if (PushKindMetadata.TryParse(span, out var kind)) + { + return kind switch + { + PushKind.Message => len >= 3, + PushKind.PMessage => len >= 4, + PushKind.SMessage => len >= 3, + _ => false, + }; + } + } + + return false; + } + + public ValueTask ReadOneAsync(CancellationToken cancellationToken = default) + => TryTakeOne(out var result) ? new(result) : ReadMoreAsync(cancellationToken); + + [DoesNotReturn] + private static void ThrowInvalidReadStatus(OperationStatus status) + => throw new InvalidOperationException($"Unexpected read status: {status}"); + + private async ValueTask ReadMoreAsync(CancellationToken cancellationToken) + { + while (true) + { + var buffer = _readBuffer.GetUncommittedMemory(); + Debug.Assert(!buffer.IsEmpty, "rule out zero-length reads"); + _reading = true; + var read = await source.ReadAsync(buffer, cancellationToken).ForAwait(); + _reading = false; + if (read <= 0) + { + // EOF + return default; + } + _readBuffer.Commit(read); + + if (TryTakeOne(out var result)) return result; + } + } + + public void Dispose() + { + bool disposed = _disposed; + _disposed = true; + _state = default; + + if (!(_reading | disposed)) _readBuffer.Release(); + _readBuffer = default; + if (!disposed) source.Dispose(); + } + + public async ValueTask ValidateAsync(CancellationToken cancellationToken = default) + { + long count = 0; + while (true) + { + var buffer = _readBuffer.GetUncommittedMemory(); + Debug.Assert(!buffer.IsEmpty, "rule out zero-length reads"); + _reading = true; + var read = await source.ReadAsync(buffer, cancellationToken).ForAwait(); + _reading = false; + if (read <= 0) + { + // EOF + return count; + } + _readBuffer.Commit(read); + while (TryTakeOne(out _, withData: false)) count++; + } + } + } + /// /// Replay the RESP messages for a pair of streams, invoking a callback per operation. /// public static async Task ReplayAsync(Stream @out, Stream @in, Action pair) { - using Arena arena = new(); - var outPipe = StreamConnection.GetReader(@out); - var inPipe = StreamConnection.GetReader(@in); - long count = 0; + using var outReader = new StreamRespReader(@out, isInbound: false); + using var inReader = new StreamRespReader(@in, isInbound: true); while (true) { - var sent = await ReadOneAsync(outPipe, arena, isInbound: false).ForAwait(); + if (!outReader.TryTakeOne(out var sent)) sent = await outReader.ReadOneAsync().ForAwait(); ContextualRedisResult received; try { do { - received = await ReadOneAsync(inPipe, arena, isInbound: true).ForAwait(); + if (!inReader.TryTakeOne(out received)) received = await inReader.ReadOneAsync().ForAwait(); if (received.IsOutOfBand && received.Result is not null) { // spoof an empty request for OOB messages @@ -93,26 +226,6 @@ public static async Task ReplayAsync(string path, Action ReadOneAsync(PipeReader input, Arena arena, bool isInbound) - { - while (true) - { - var readResult = await input.ReadAsync().ForAwait(); - var buffer = readResult.Buffer; - int handled = 0; - var result = buffer.IsEmpty ? default : ProcessBuffer(arena, ref buffer, isInbound); - input.AdvanceTo(buffer.Start, buffer.End); - - if (result.Result is not null) return result; - - if (handled == 0 && readResult.IsCompleted) - { - break; // no more data, or trailing incomplete messages - } - } - return default; - } - /// /// Validate a RESP stream and return the number of top-level RESP fragments. /// @@ -152,63 +265,11 @@ public static async Task ValidateAsync(string path) /// public static async Task ValidateAsync(Stream stream) { - using var arena = new Arena(); - var input = StreamConnection.GetReader(stream); - long total = 0, position = 0; - while (true) - { - var readResult = await input.ReadAsync().ForAwait(); - var buffer = readResult.Buffer; - int handled = 0; - if (!buffer.IsEmpty) - { - try - { - ProcessBuffer(arena, ref buffer, ref position, ref handled); // updates buffer.Start - } - catch (Exception ex) - { - throw new InvalidOperationException($"Invalid fragment starting at {position} (fragment {total + handled})", ex); - } - total += handled; - } - - input.AdvanceTo(buffer.Start, buffer.End); - - if (handled == 0 && readResult.IsCompleted) - { - break; // no more data, or trailing incomplete messages - } - } - return total; - } - private static void ProcessBuffer(Arena arena, ref ReadOnlySequence buffer, ref long position, ref int messageCount) - { - while (!buffer.IsEmpty) - { - var reader = new BufferReader(buffer); - try - { - var result = TryParseResult(true, arena, in buffer, ref reader, true, null); - if (result.HasValue) - { - buffer = reader.SliceFromCurrent(); - position += reader.TotalConsumed; - messageCount++; - } - else - { - break; // remaining buffer isn't enough; give up - } - } - finally - { - arena.Reset(); - } - } + using var reader = new StreamRespReader(stream, isInbound: false); + return await reader.ValidateAsync(); } - private readonly struct ContextualRedisResult + internal readonly struct ContextualRedisResult { public readonly RedisResult? Result; public readonly bool IsOutOfBand; @@ -219,42 +280,6 @@ public ContextualRedisResult(RedisResult? result, bool isOutOfBand) } } - private static ContextualRedisResult ProcessBuffer(Arena arena, ref ReadOnlySequence buffer, bool isInbound) - { - if (!buffer.IsEmpty) - { - var reader = new BufferReader(buffer); - try - { - var result = TryParseResult(true, arena, in buffer, ref reader, true, null); - bool isOutOfBand = result.Resp3Type == ResultType.Push - || (isInbound && result.Resp2TypeArray == ResultType.Array && IsArrayOutOfBand(result)); - if (result.HasValue) - { - buffer = reader.SliceFromCurrent(); - if (!RedisResult.TryCreate(null, result, out var parsed)) - { - throw new InvalidOperationException("Unable to parse raw result to RedisResult"); - } - return new(parsed, isOutOfBand); - } - } - finally - { - arena.Reset(); - } - } - return default; - - static bool IsArrayOutOfBand(in RawResult result) - { - var items = result.GetItems(); - return (items.Length >= 3 && (items[0].IsEqual(message) || items[0].IsEqual(smessage))) - || (items.Length >= 4 && items[0].IsEqual(pmessage)); - } - } - private static readonly CommandBytes message = "message", pmessage = "pmessage", smessage = "smessage"; - /// /// Create a new instance of a . /// diff --git a/src/StackExchange.Redis/ConfigurationOptions.cs b/src/StackExchange.Redis/ConfigurationOptions.cs index 641fccc95..1a0cdd1a6 100644 --- a/src/StackExchange.Redis/ConfigurationOptions.cs +++ b/src/StackExchange.Redis/ConfigurationOptions.cs @@ -330,9 +330,9 @@ internal static LocalCertificateSelectionCallback CreatePemUserCertificateCallba { // PEM handshakes not universally supported and causes a runtime error about ephemeral certificates; to avoid, export as PFX using var pem = X509Certificate2.CreateFromPemFile(userCertificatePath, userKeyPath); -#pragma warning disable SYSLIB0057 // Type or member is obsolete +#pragma warning disable SYSLIB0057 // X509 loading var pfx = new X509Certificate2(pem.Export(X509ContentType.Pfx)); -#pragma warning restore SYSLIB0057 // Type or member is obsolete +#pragma warning restore SYSLIB0057 // X509 loading return (sender, targetHost, localCertificates, remoteCertificate, acceptableIssuers) => pfx; } @@ -340,9 +340,9 @@ internal static LocalCertificateSelectionCallback CreatePemUserCertificateCallba internal static LocalCertificateSelectionCallback CreatePfxUserCertificateCallback(string userCertificatePath, string? password, X509KeyStorageFlags storageFlags = X509KeyStorageFlags.DefaultKeySet) { -#pragma warning disable SYSLIB0057 +#pragma warning disable SYSLIB0057 // X509 loading var pfx = new X509Certificate2(userCertificatePath, password ?? "", storageFlags); -#pragma warning restore SYSLIB0057 +#pragma warning restore SYSLIB0057 // X509 loading return (sender, targetHost, localCertificates, remoteCertificate, acceptableIssuers) => pfx; } @@ -353,9 +353,10 @@ internal static LocalCertificateSelectionCallback CreatePfxUserCertificateCallba public void TrustIssuer(X509Certificate2 issuer) => CertificateValidationCallback = TrustIssuerCallback(issuer); internal static RemoteCertificateValidationCallback TrustIssuerCallback(string issuerCertificatePath) -#pragma warning disable SYSLIB0057 +#pragma warning disable SYSLIB0057 // X509 loading => TrustIssuerCallback(new X509Certificate2(issuerCertificatePath)); -#pragma warning restore SYSLIB0057 +#pragma warning restore SYSLIB0057 // X509 loading + private static RemoteCertificateValidationCallback TrustIssuerCallback(X509Certificate2 issuer) { if (issuer == null) throw new ArgumentNullException(nameof(issuer)); @@ -696,6 +697,7 @@ public int ResponseTimeout /// This is only used when a is created. /// Modifying it afterwards will have no effect on already-created multiplexers. /// + [Obsolete("SocketManager is no longer used by StackExchange.Redis")] public SocketManager? SocketManager { get; set; } #if NET @@ -825,7 +827,9 @@ public static ConfigurationOptions Parse(string configuration, bool ignoreUnknow CertificateValidationCallback = CertificateValidationCallback, CertificateSelectionCallback = CertificateSelectionCallback, ChannelPrefix = ChannelPrefix.Clone(), +#pragma warning disable CS0618 // Type or member is obsolete SocketManager = SocketManager, +#pragma warning restore CS0618 // Type or member is obsolete connectRetry = connectRetry, configCheckSeconds = configCheckSeconds, responseTimeout = responseTimeout, @@ -847,6 +851,10 @@ public static ConfigurationOptions Parse(string configuration, bool ignoreUnknow heartbeatInterval = heartbeatInterval, heartbeatConsistencyChecks = heartbeatConsistencyChecks, highIntegrity = highIntegrity, + WriteMode = WriteMode, +#if DEBUG + OutputLog = OutputLog, +#endif }; /// @@ -982,7 +990,9 @@ private void Clear() CertificateSelection = null; CertificateValidation = null; ChannelPrefix = default; +#pragma warning disable CS0618 // Type or member is obsolete SocketManager = null; +#pragma warning restore CS0618 // Type or member is obsolete Tunnel = null; } @@ -1171,6 +1181,12 @@ private ConfigurationOptions DoParse(string configuration, bool ignoreUnknown) /// public RedisProtocol? Protocol { get; set; } + internal BufferedStreamWriter.WriteMode WriteMode { get; set; } + +#if DEBUG + internal Action? OutputLog; +#endif + internal bool TryResp3() { // note: deliberately leaving the IsAvailable duplicated to use short-circuit diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.ReaderWriter.cs b/src/StackExchange.Redis/ConnectionMultiplexer.ReaderWriter.cs deleted file mode 100644 index a30da7865..000000000 --- a/src/StackExchange.Redis/ConnectionMultiplexer.ReaderWriter.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace StackExchange.Redis; - -public partial class ConnectionMultiplexer -{ - internal SocketManager? SocketManager { get; private set; } - - [MemberNotNull(nameof(SocketManager))] - private void OnCreateReaderWriter(ConfigurationOptions configuration) - { - SocketManager = configuration.SocketManager ?? SocketManager.Shared; - } - - private void OnCloseReaderWriter() - { - SocketManager = null; - } -} diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index a5995046e..74bbb55f2 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -147,7 +147,6 @@ private ConnectionMultiplexer(ConfigurationOptions configuration, ServerType? se map.AssertAvailable(RedisCommand.EXISTS); } - OnCreateReaderWriter(configuration); ServerSelectionStrategy = new ServerSelectionStrategy(this); var configChannel = configuration.ConfigurationChannel; @@ -352,7 +351,7 @@ internal void CheckMessage(Message message) } // using >= here because we will be adding 1 for the command itself (which is an argument for the purposes of the multi-bulk protocol) - if (message.ArgCount >= PhysicalConnection.REDIS_MAX_ARGS) + if (message.ArgCount >= MessageWriter.REDIS_MAX_ARGS) { throw ExceptionFactory.TooManyArgs(message.CommandAndKey, message.ArgCount); } @@ -2305,7 +2304,6 @@ public void Close(bool allowCommandsToComplete = true) WaitAllIgnoreErrors(quits); } DisposeAndClearServers(); - OnCloseReaderWriter(); OnClosing(true); Interlocked.Increment(ref _connectionCloseCount); } diff --git a/src/StackExchange.Redis/CursorEnumerable.cs b/src/StackExchange.Redis/CursorEnumerable.cs index 921d83ce0..8f9863bb3 100644 --- a/src/StackExchange.Redis/CursorEnumerable.cs +++ b/src/StackExchange.Redis/CursorEnumerable.cs @@ -55,6 +55,17 @@ internal readonly struct ScanResult public readonly T[]? ValuesOversized; public readonly int Count; public readonly bool IsPooled; + public ReadOnlySpan Values => ValuesOversized is null ? [] : ValuesOversized.AsSpan(0, Count); + + public void Recycle() + { + if (IsPooled && ValuesOversized != null) + { + ArrayPool.Shared.Return(ValuesOversized); + } + + Unsafe.AsRef(in this) = default; // best effort wipe + } public ScanResult(RedisValue cursor, T[]? valuesOversized, int count, bool isPooled) { Cursor = cursor; diff --git a/src/StackExchange.Redis/Enums/CommandFlags.cs b/src/StackExchange.Redis/Enums/CommandFlags.cs index 83331a3c5..fc3c6070d 100644 --- a/src/StackExchange.Redis/Enums/CommandFlags.cs +++ b/src/StackExchange.Redis/Enums/CommandFlags.cs @@ -106,7 +106,7 @@ public enum CommandFlags /// NoScriptCache = 512, - // 1024: Removed - was used for async timeout checks; never user-specified, so not visible on the public API + // 1024: used for "no flush"; never user-specified, so not visible on the public API // 2048: Use subscription connection type; never user-specified, so not visible on the public API } diff --git a/src/StackExchange.Redis/Enums/RedisType.cs b/src/StackExchange.Redis/Enums/RedisType.cs index 90a41165b..429dd1b9f 100644 --- a/src/StackExchange.Redis/Enums/RedisType.cs +++ b/src/StackExchange.Redis/Enums/RedisType.cs @@ -1,4 +1,7 @@ -namespace StackExchange.Redis +using System; +using RESPite; + +namespace StackExchange.Redis { /// /// The intrinsic data-types supported by redis. @@ -9,6 +12,7 @@ public enum RedisType /// /// The specified key does not exist. /// + [AsciiHash("")] None, /// @@ -17,6 +21,7 @@ public enum RedisType /// A String value can be at max 512 Megabytes in length. /// /// + [AsciiHash("string")] String, /// @@ -25,6 +30,7 @@ public enum RedisType /// on the tail (on the right) of the list. /// /// + [AsciiHash("list")] List, /// @@ -35,6 +41,7 @@ public enum RedisType /// Practically speaking this means that adding a member does not require a check if exists then add operation. /// /// + [AsciiHash("set")] Set, /// @@ -44,6 +51,7 @@ public enum RedisType /// While members are unique, scores may be repeated. /// /// + [AsciiHash("zset")] SortedSet, /// @@ -51,6 +59,7 @@ public enum RedisType /// to represent objects (e.g. A User with a number of fields like name, surname, age, and so forth). /// /// + [AsciiHash("hash")] Hash, /// @@ -59,6 +68,7 @@ public enum RedisType /// stream contains a unique message ID and a list of name/value pairs containing the entry's data. /// /// + [AsciiHash("stream")] Stream, /// @@ -70,6 +80,16 @@ public enum RedisType /// 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. /// + [AsciiHash("vectorset")] VectorSet, } + + /// + /// Metadata and parsing methods for RedisType. + /// + internal static partial class RedisTypeMetadata + { + [AsciiHash] + internal static partial bool TryParse(ReadOnlySpan value, out RedisType redisType); + } } diff --git a/src/StackExchange.Redis/Enums/ServerType.cs b/src/StackExchange.Redis/Enums/ServerType.cs index ef49a8449..43bc087c5 100644 --- a/src/StackExchange.Redis/Enums/ServerType.cs +++ b/src/StackExchange.Redis/Enums/ServerType.cs @@ -43,7 +43,7 @@ internal static class ServerTypeExtensions }; /// - /// Whether a server type supports . + /// Whether a server type supports . /// internal static bool SupportsAutoConfigure(this ServerType type) => type switch { diff --git a/src/StackExchange.Redis/ExceptionFactory.cs b/src/StackExchange.Redis/ExceptionFactory.cs index 434abce17..a6e86036b 100644 --- a/src/StackExchange.Redis/ExceptionFactory.cs +++ b/src/StackExchange.Redis/ExceptionFactory.cs @@ -28,7 +28,7 @@ internal static Exception CommandDisabled(string command) => new RedisCommandException("This operation has been disabled in the command-map and cannot be used: " + command); internal static Exception TooManyArgs(string command, int argCount) - => new RedisCommandException($"This operation would involve too many arguments ({argCount + 1} vs the redis limit of {PhysicalConnection.REDIS_MAX_ARGS}): {command}"); + => new RedisCommandException($"This operation would involve too many arguments ({argCount + 1} vs the redis limit of {MessageWriter.REDIS_MAX_ARGS}): {command}"); internal static Exception ConnectionFailure(bool includeDetail, ConnectionFailureType failureType, string message, ServerEndPoint? server) { @@ -367,7 +367,6 @@ private static void AddCommonDetail( Add(data, sb, "Abort-On-Connect", "aoc", multiplexer.RawConfig.AbortOnConnectFail ? "1" : "0"); } Add(data, sb, "Multiplexer-Connects", "mc", $"{multiplexer._connectAttemptCount}/{multiplexer._connectCompletedCount}/{multiplexer._connectionCloseCount}"); - Add(data, sb, "Manager", "mgr", multiplexer.SocketManager?.GetState()); Add(data, sb, "Client-Name", "clientName", multiplexer.ClientName); if (message != null) diff --git a/src/StackExchange.Redis/Expiration.cs b/src/StackExchange.Redis/Expiration.cs index e04094358..738b0b111 100644 --- a/src/StackExchange.Redis/Expiration.cs +++ b/src/StackExchange.Redis/Expiration.cs @@ -244,7 +244,7 @@ private static void ThrowMode(ExpirationMode mode) => _ => 2, }; - internal void WriteTo(PhysicalConnection physical) + internal void WriteTo(in MessageWriter writer) { var mode = Mode; switch (Mode) @@ -252,13 +252,13 @@ internal void WriteTo(PhysicalConnection physical) case ExpirationMode.Default or ExpirationMode.NotUsed: break; case ExpirationMode.KeepTtl: - physical.WriteBulkString("KEEPTTL"u8); + writer.WriteBulkString("KEEPTTL"u8); break; case ExpirationMode.Persist: - physical.WriteBulkString("PERSIST"u8); + writer.WriteBulkString("PERSIST"u8); break; default: - physical.WriteBulkString(mode switch + writer.WriteBulkString(mode switch { ExpirationMode.RelativeSeconds => "EX"u8, ExpirationMode.RelativeMilliseconds => "PX"u8, @@ -266,7 +266,7 @@ internal void WriteTo(PhysicalConnection physical) ExpirationMode.AbsoluteMilliseconds => "PXAT"u8, _ => default, }); - physical.WriteBulkString(Value); + writer.WriteBulkString(Value); break; } } diff --git a/src/StackExchange.Redis/ExtensionMethods.cs b/src/StackExchange.Redis/ExtensionMethods.cs index e5a5c4d4d..240a8752b 100644 --- a/src/StackExchange.Redis/ExtensionMethods.cs +++ b/src/StackExchange.Redis/ExtensionMethods.cs @@ -8,7 +8,6 @@ using System.Security.Cryptography.X509Certificates; using System.Text; using System.Threading.Tasks; -using Pipelines.Sockets.Unofficial.Arenas; namespace StackExchange.Redis { @@ -329,13 +328,5 @@ internal static int VectorSafeIndexOfCRLF(this ReadOnlySpan span) return -1; } #endif - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static T[]? ToArray(in this RawResult result, Projection selector) - => result.IsNull ? null : result.GetItems().ToArray(selector); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static TTo[]? ToArray(in this RawResult result, Projection selector, in TState state) - => result.IsNull ? null : result.GetItems().ToArray(selector, in state); } } diff --git a/src/StackExchange.Redis/FrameworkShims.IsExternalInit.cs b/src/StackExchange.Redis/FrameworkShims.IsExternalInit.cs new file mode 100644 index 000000000..417975050 --- /dev/null +++ b/src/StackExchange.Redis/FrameworkShims.IsExternalInit.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; + +#if NET5_0_OR_GREATER +// context: https://github.com/StackExchange/StackExchange.Redis/issues/2619 +[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Runtime.CompilerServices.IsExternalInit))] +#else + +// To support { get; init; } properties +// ReSharper disable once CheckNamespace +namespace System.Runtime.CompilerServices +{ + [EditorBrowsable(EditorBrowsableState.Never)] + internal static class IsExternalInit { } +} +#endif diff --git a/src/StackExchange.Redis/FrameworkShims.cs b/src/StackExchange.Redis/FrameworkShims.cs deleted file mode 100644 index ce954406d..000000000 --- a/src/StackExchange.Redis/FrameworkShims.cs +++ /dev/null @@ -1,78 +0,0 @@ -#pragma warning disable SA1403 // single namespace - -#if NET -// context: https://github.com/StackExchange/StackExchange.Redis/issues/2619 -[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Runtime.CompilerServices.IsExternalInit))] -#else -// To support { get; init; } properties -using System.ComponentModel; -using System.Text; - -namespace System.Runtime.CompilerServices -{ - [EditorBrowsable(EditorBrowsableState.Never)] - internal static class IsExternalInit { } -} -#endif - -#if !NET10_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 - -#if !NET - -namespace System.Text -{ - internal static class EncodingExtensions - { - public static unsafe int GetBytes(this Encoding encoding, ReadOnlySpan source, Span destination) - { - 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) - { - 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) - { - fixed (byte* bPtr = source) - { - return encoding.GetCharCount(bPtr, source.Length); - } - } - - public static unsafe string GetString(this Encoding encoding, ReadOnlySpan source) - { - fixed (byte* bPtr = source) - { - return encoding.GetString(bPtr, source.Length); - } - } - } -} -#endif - - -#pragma warning restore SA1403 diff --git a/src/StackExchange.Redis/HelloField.cs b/src/StackExchange.Redis/HelloField.cs new file mode 100644 index 000000000..e5d730ce4 --- /dev/null +++ b/src/StackExchange.Redis/HelloField.cs @@ -0,0 +1,55 @@ +using System; +using RESPite; + +namespace StackExchange.Redis; + +/// +/// Fields that can appear in a HELLO command response. +/// +internal enum HelloField +{ + /// + /// Unknown or unrecognized field. + /// + [AsciiHash("")] + Unknown = 0, + + /// + /// Redis server version. + /// + [AsciiHash("version")] + Version, + + /// + /// Protocol version (2 or 3). + /// + [AsciiHash("proto")] + Proto, + + /// + /// Connection ID. + /// + [AsciiHash("id")] + Id, + + /// + /// Server mode (standalone, cluster, sentinel). + /// + [AsciiHash("mode")] + Mode, + + /// + /// Server role (master/primary, slave/replica). + /// + [AsciiHash("role")] + Role, +} + +/// +/// Metadata and parsing methods for HelloField. +/// +internal static partial class HelloFieldMetadata +{ + [AsciiHash] + internal static partial bool TryParse(ReadOnlySpan value, out HelloField field); +} diff --git a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs index 71644c010..10b2d6357 100644 --- a/src/StackExchange.Redis/HotKeys.ResultProcessor.cs +++ b/src/StackExchange.Redis/HotKeys.ResultProcessor.cs @@ -1,4 +1,8 @@ -namespace StackExchange.Redis; +using System; +using RESPite; +using RESPite.Messages; + +namespace StackExchange.Redis; public sealed partial class HotKeysResult { @@ -6,21 +10,22 @@ public sealed partial class HotKeysResult private sealed class HotKeysResultProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.IsNull) + if (reader.IsNull) { SetResult(message, null); return true; } // an array with a single element that *is* an array/map that is the results - if (result is { Resp2TypeArray: ResultType.Array, ItemsCount: 1 }) + if (reader.IsAggregate && reader.AggregateLengthIs(1)) { - ref readonly RawResult inner = ref result[0]; - if (inner is { Resp2TypeArray: ResultType.Array, IsNull: false }) + var iter = reader.AggregateChildren(); + iter.DemandNext(); + if (iter.Value.IsAggregate && !iter.Value.IsNull) { - var hotKeys = new HotKeysResult(in inner); + var hotKeys = new HotKeysResult(ref iter.Value); SetResult(message, hotKeys); return true; } @@ -30,160 +35,146 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes } } - private HotKeysResult(in RawResult result) + private HotKeysResult(ref RespReader reader) { var metrics = HotKeysMetrics.None; // we infer this from the keys present - var iter = result.GetItems().GetEnumerator(); - while (iter.MoveNext()) + int count = reader.AggregateLength(); + if ((count & 1) != 0) return; // must be even (key-value pairs) + + while (reader.TryMoveNext() && reader.IsScalar) { - if (!iter.Current.TryParse(HotKeysFieldMetadata.TryParse, out HotKeysField field)) - field = HotKeysField.Unknown; + HotKeysField field; + unsafe + { + if (!reader.TryParseScalar(&HotKeysFieldMetadata.TryParse, out field)) + { + field = HotKeysField.Unknown; + } + } - if (!iter.MoveNext()) break; // lies about the length! - ref readonly RawResult value = ref iter.Current; + // Move to value + if (!reader.TryMoveNext()) break; long i64; switch (field) { case HotKeysField.TrackingActive: - TrackingActive = value.GetBoolean(); + TrackingActive = reader.ReadBoolean(); break; - case HotKeysField.SampleRatio when value.TryGetInt64(out i64): + case HotKeysField.SampleRatio when reader.TryReadInt64(out i64): SampleRatio = i64; break; - case HotKeysField.SelectedSlots when value.Resp2TypeArray is ResultType.Array: - var len = value.ItemsCount; - if (len == 0) - { - _selectedSlots = []; - continue; - } - - var items = value.GetItems().GetEnumerator(); - var slots = len == 1 ? null : new SlotRange[len]; - for (int i = 0; i < len && items.MoveNext(); i++) - { - ref readonly RawResult pair = ref items.Current; - if (pair.Resp2TypeArray is ResultType.Array) + case HotKeysField.SelectedSlots when reader.IsAggregate: + var slotRanges = reader.ReadPastArray( + static (ref RespReader slotReader) => { + if (!slotReader.IsAggregate) return default; + + int pairLen = slotReader.AggregateLength(); long from = -1, to = -1; - switch (pair.ItemsCount) - { - case 1 when pair[0].TryGetInt64(out from): - to = from; // single slot - break; - case 2 when pair[0].TryGetInt64(out from) && pair[1].TryGetInt64(out to): - break; - } - if (from < SlotRange.MinSlot) - { - // skip invalid ranges - } - else if (len == 1 & from == SlotRange.MinSlot & to == SlotRange.MaxSlot) + var pairIter = slotReader.AggregateChildren(); + if (pairLen >= 1 && pairIter.MoveNext() && pairIter.Value.TryReadInt64(out from)) { - // this is the "normal" case when no slot filter was applied - slots = SlotRange.SharedAllSlots; // avoid the alloc + to = from; // single slot + if (pairLen >= 2 && pairIter.MoveNext() && pairIter.Value.TryReadInt64(out to)) + { + // to is now set + } } - else - { - slots ??= new SlotRange[len]; - slots[i] = new((int)from, (int)to); - } - } + + return from >= SlotRange.MinSlot ? new SlotRange((int)from, (int)to) : default; + }, + scalar: false); + + if (slotRanges is { Length: 1 } && slotRanges[0].From == SlotRange.MinSlot && slotRanges[0].To == SlotRange.MaxSlot) + { + // this is the "normal" case when no slot filter was applied + _selectedSlots = SlotRange.SharedAllSlots; // avoid the alloc + } + else + { + _selectedSlots = slotRanges ?? []; } - _selectedSlots = slots; break; - case HotKeysField.AllCommandsAllSlotsUs when value.TryGetInt64(out i64): + case HotKeysField.AllCommandsAllSlotsUs when reader.TryReadInt64(out i64): AllCommandsAllSlotsMicroseconds = i64; break; - case HotKeysField.AllCommandsSelectedSlotsUs when value.TryGetInt64(out i64): + case HotKeysField.AllCommandsSelectedSlotsUs when reader.TryReadInt64(out i64): AllCommandSelectedSlotsMicroseconds = i64; break; - case HotKeysField.SampledCommandSelectedSlotsUs when value.TryGetInt64(out i64): - case HotKeysField.SampledCommandsSelectedSlotsUs when value.TryGetInt64(out i64): + case HotKeysField.SampledCommandSelectedSlotsUs when reader.TryReadInt64(out i64): + case HotKeysField.SampledCommandsSelectedSlotsUs when reader.TryReadInt64(out i64): SampledCommandsSelectedSlotsMicroseconds = i64; break; - case HotKeysField.NetBytesAllCommandsAllSlots when value.TryGetInt64(out i64): + case HotKeysField.NetBytesAllCommandsAllSlots when reader.TryReadInt64(out i64): AllCommandsAllSlotsNetworkBytes = i64; break; - case HotKeysField.NetBytesAllCommandsSelectedSlots when value.TryGetInt64(out i64): + case HotKeysField.NetBytesAllCommandsSelectedSlots when reader.TryReadInt64(out i64): NetworkBytesAllCommandsSelectedSlotsRaw = i64; break; - case HotKeysField.NetBytesSampledCommandsSelectedSlots when value.TryGetInt64(out i64): + case HotKeysField.NetBytesSampledCommandsSelectedSlots when reader.TryReadInt64(out i64): NetworkBytesSampledCommandsSelectedSlotsRaw = i64; break; - case HotKeysField.CollectionStartTimeUnixMs when value.TryGetInt64(out i64): + case HotKeysField.CollectionStartTimeUnixMs when reader.TryReadInt64(out i64): CollectionStartTimeUnixMilliseconds = i64; break; - case HotKeysField.CollectionDurationMs when value.TryGetInt64(out i64): + case HotKeysField.CollectionDurationMs when reader.TryReadInt64(out i64): CollectionDurationMicroseconds = i64 * 1000; // ms vs us is in question: support both, and abstract it from the caller break; - case HotKeysField.CollectionDurationUs when value.TryGetInt64(out i64): + case HotKeysField.CollectionDurationUs when reader.TryReadInt64(out i64): CollectionDurationMicroseconds = i64; break; - case HotKeysField.TotalCpuTimeSysMs when value.TryGetInt64(out i64): + case HotKeysField.TotalCpuTimeSysMs when reader.TryReadInt64(out i64): metrics |= HotKeysMetrics.Cpu; TotalCpuTimeSystemMicroseconds = i64 * 1000; // ms vs us is in question: support both, and abstract it from the caller break; - case HotKeysField.TotalCpuTimeSysUs when value.TryGetInt64(out i64): + case HotKeysField.TotalCpuTimeSysUs when reader.TryReadInt64(out i64): metrics |= HotKeysMetrics.Cpu; TotalCpuTimeSystemMicroseconds = i64; break; - case HotKeysField.TotalCpuTimeUserMs when value.TryGetInt64(out i64): + case HotKeysField.TotalCpuTimeUserMs when reader.TryReadInt64(out i64): metrics |= HotKeysMetrics.Cpu; TotalCpuTimeUserMicroseconds = i64 * 1000; // ms vs us is in question: support both, and abstract it from the caller break; - case HotKeysField.TotalCpuTimeUserUs when value.TryGetInt64(out i64): + case HotKeysField.TotalCpuTimeUserUs when reader.TryReadInt64(out i64): metrics |= HotKeysMetrics.Cpu; TotalCpuTimeUserMicroseconds = i64; break; - case HotKeysField.TotalNetBytes when value.TryGetInt64(out i64): + case HotKeysField.TotalNetBytes when reader.TryReadInt64(out i64): metrics |= HotKeysMetrics.Network; TotalNetworkBytesRaw = i64; break; - case HotKeysField.ByCpuTimeUs when value.Resp2TypeArray is ResultType.Array: + case HotKeysField.ByCpuTimeUs when reader.IsAggregate: metrics |= HotKeysMetrics.Cpu; - len = value.ItemsCount / 2; - if (len == 0) + int cpuLen = reader.AggregateLength() / 2; + var cpuTime = new MetricKeyCpu[cpuLen]; + var cpuIter = reader.AggregateChildren(); + int cpuIdx = 0; + while (cpuIter.MoveNext() && cpuIdx < cpuLen) { - _cpuByKey = []; - continue; - } - - var cpuTime = new MetricKeyCpu[len]; - items = value.GetItems().GetEnumerator(); - for (int i = 0; i < len && items.MoveNext(); i++) - { - var metricKey = items.Current.AsRedisKey(); - if (items.MoveNext() && items.Current.TryGetInt64(out var metricValue)) + var metricKey = cpuIter.Value.ReadRedisKey(); + if (cpuIter.MoveNext() && cpuIter.Value.TryReadInt64(out var metricValue)) { - cpuTime[i] = new(metricKey, metricValue); + cpuTime[cpuIdx++] = new(metricKey, metricValue); } } - _cpuByKey = cpuTime; break; - case HotKeysField.ByNetBytes when value.Resp2TypeArray is ResultType.Array: + case HotKeysField.ByNetBytes when reader.IsAggregate: metrics |= HotKeysMetrics.Network; - len = value.ItemsCount / 2; - if (len == 0) + int netLen = reader.AggregateLength() / 2; + var netBytes = new MetricKeyBytes[netLen]; + var netIter = reader.AggregateChildren(); + int netIdx = 0; + while (netIter.MoveNext() && netIdx < netLen) { - _networkBytesByKey = []; - continue; - } - - var netBytes = new MetricKeyBytes[len]; - items = value.GetItems().GetEnumerator(); - for (int i = 0; i < len && items.MoveNext(); i++) - { - var metricKey = items.Current.AsRedisKey(); - if (items.MoveNext() && items.Current.TryGetInt64(out var metricValue)) + var metricKey = netIter.Value.ReadRedisKey(); + if (netIter.MoveNext() && netIter.Value.TryReadInt64(out var metricValue)) { - netBytes[i] = new(metricKey, metricValue); + netBytes[netIdx++] = new(metricKey, metricValue); } } - _networkBytesByKey = netBytes; break; } // switch diff --git a/src/StackExchange.Redis/HotKeys.StartMessage.cs b/src/StackExchange.Redis/HotKeys.StartMessage.cs index c9f0bc371..8793e79df 100644 --- a/src/StackExchange.Redis/HotKeys.StartMessage.cs +++ b/src/StackExchange.Redis/HotKeys.StartMessage.cs @@ -1,5 +1,4 @@ using System; -using System.Threading.Tasks; namespace StackExchange.Redis; @@ -13,7 +12,7 @@ internal sealed class HotKeysStartMessage( long sampleRatio, int[]? slots) : Message(-1, flags, RedisCommand.HOTKEYS) { - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { /* HOTKEYS START @@ -23,41 +22,41 @@ [DURATION duration] [SAMPLE ratio] [SLOTS count slot…] */ - physical.WriteHeader(Command, ArgCount); - physical.WriteBulkString("START"u8); - physical.WriteBulkString("METRICS"u8); + writer.WriteHeader(Command, ArgCount); + writer.WriteBulkString("START"u8); + writer.WriteBulkString("METRICS"u8); var metricCount = 0; if ((metrics & HotKeysMetrics.Cpu) != 0) metricCount++; if ((metrics & HotKeysMetrics.Network) != 0) metricCount++; - physical.WriteBulkString(metricCount); - if ((metrics & HotKeysMetrics.Cpu) != 0) physical.WriteBulkString("CPU"u8); - if ((metrics & HotKeysMetrics.Network) != 0) physical.WriteBulkString("NET"u8); + writer.WriteBulkString(metricCount); + if ((metrics & HotKeysMetrics.Cpu) != 0) writer.WriteBulkString("CPU"u8); + if ((metrics & HotKeysMetrics.Network) != 0) writer.WriteBulkString("NET"u8); if (count != 0) { - physical.WriteBulkString("COUNT"u8); - physical.WriteBulkString(count); + writer.WriteBulkString("COUNT"u8); + writer.WriteBulkString(count); } if (duration != TimeSpan.Zero) { - physical.WriteBulkString("DURATION"u8); - physical.WriteBulkString(Math.Ceiling(duration.TotalSeconds)); + writer.WriteBulkString("DURATION"u8); + writer.WriteBulkString(Math.Ceiling(duration.TotalSeconds)); } if (sampleRatio != 1) { - physical.WriteBulkString("SAMPLE"u8); - physical.WriteBulkString(sampleRatio); + writer.WriteBulkString("SAMPLE"u8); + writer.WriteBulkString(sampleRatio); } if (slots is { Length: > 0 }) { - physical.WriteBulkString("SLOTS"u8); - physical.WriteBulkString(slots.Length); + writer.WriteBulkString("SLOTS"u8); + writer.WriteBulkString(slots.Length); foreach (var slot in slots) { - physical.WriteBulkString(slot); + writer.WriteBulkString(slot); } } } diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.VectorSets.cs b/src/StackExchange.Redis/Interfaces/IDatabase.VectorSets.cs index 8e6444ea8..bc54bb4c1 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabase.VectorSets.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabase.VectorSets.cs @@ -1,4 +1,3 @@ -using System; using System.Diagnostics.CodeAnalysis; using RESPite; diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs index c581470ca..9719861d8 100644 --- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs +++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Net; using System.Threading.Tasks; using RESPite; diff --git a/src/StackExchange.Redis/Interfaces/IServer.cs b/src/StackExchange.Redis/Interfaces/IServer.cs index fdd3d6872..944bf63fd 100644 --- a/src/StackExchange.Redis/Interfaces/IServer.cs +++ b/src/StackExchange.Redis/Interfaces/IServer.cs @@ -815,7 +815,11 @@ internal static class IServerExtensions /// 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 + /// + /// For testing only: Check if the server can simulate connection failure. + /// + /// The server to check. + internal static bool CanSimulateConnectionFailure(this IServer server) => + (server as RedisServer)?.CanSimulateConnectionFailure == true; } } diff --git a/src/StackExchange.Redis/KeyNotification.cs b/src/StackExchange.Redis/KeyNotification.cs index 08c157bc6..b8142cfe8 100644 --- a/src/StackExchange.Redis/KeyNotification.cs +++ b/src/StackExchange.Redis/KeyNotification.cs @@ -1,7 +1,6 @@ using System; using System.Buffers; using System.Buffers.Text; -using System.Diagnostics; using System.Runtime.CompilerServices; using System.Text; using RESPite; @@ -41,8 +40,8 @@ public static bool TryParse(scoped in RedisChannel channel, scoped in RedisValue var hashCS = AsciiHash.HashCS(prefix); switch (hashCS) { - case KeyEventPrefix.HashCS when KeyEventPrefix.IsCS(prefix, hashCS): case KeySpacePrefix.HashCS when KeySpacePrefix.IsCS(prefix, hashCS): + case KeyEventPrefix.HashCS when KeyEventPrefix.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) { diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.VectorSets.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.VectorSets.cs index ad4efe916..89ba07c99 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.VectorSets.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixed.VectorSets.cs @@ -1,5 +1,4 @@ -using System; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using RESPite; diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.VectorSets.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.VectorSets.cs index 83fbb2f85..0d025dc6c 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.VectorSets.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.VectorSets.cs @@ -1,6 +1,4 @@ -using System; - -// ReSharper disable once CheckNamespace +// ReSharper disable once CheckNamespace namespace StackExchange.Redis.KeyspaceIsolation; internal sealed partial class KeyPrefixedDatabase diff --git a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs index 01fe28505..498159578 100644 --- a/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs +++ b/src/StackExchange.Redis/KeyspaceIsolation/KeyPrefixedDatabase.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; using System.Net; namespace StackExchange.Redis.KeyspaceIsolation diff --git a/src/StackExchange.Redis/LCSField.cs b/src/StackExchange.Redis/LCSField.cs new file mode 100644 index 000000000..2e2dbd8d0 --- /dev/null +++ b/src/StackExchange.Redis/LCSField.cs @@ -0,0 +1,37 @@ +using System; +using RESPite; + +namespace StackExchange.Redis; + +/// +/// Fields that can appear in an LCS (Longest Common Subsequence) command response. +/// +internal enum LCSField +{ + /// + /// Unknown or unrecognized field. + /// + [AsciiHash("")] + Unknown = 0, + + /// + /// The matches array. + /// + [AsciiHash("matches")] + Matches, + + /// + /// The length value. + /// + [AsciiHash("len")] + Len, +} + +/// +/// Metadata and parsing methods for LCSField. +/// +internal static partial class LCSFieldMetadata +{ + [AsciiHash] + internal static partial bool TryParse(ReadOnlySpan value, out LCSField field); +} diff --git a/src/StackExchange.Redis/LoggerExtensions.cs b/src/StackExchange.Redis/LoggerExtensions.cs index 4a43514e4..381ee4426 100644 --- a/src/StackExchange.Redis/LoggerExtensions.cs +++ b/src/StackExchange.Redis/LoggerExtensions.cs @@ -494,7 +494,7 @@ internal static void LogWithThreadPoolStats(this ILogger? log, string message) Level = LogLevel.Information, EventId = 71, Message = "Response from {BridgeName} / {CommandAndKey}: {Result}")] - internal static partial void LogInformationResponse(this ILogger logger, string? bridgeName, string commandAndKey, RawResult result); + internal static partial void LogInformationResponse(this ILogger logger, string? bridgeName, string commandAndKey, string result); [LoggerMessage( Level = LogLevel.Information, diff --git a/src/StackExchange.Redis/Message.ValueCondition.cs b/src/StackExchange.Redis/Message.ValueCondition.cs index 53ddc651b..d6b2e79b5 100644 --- a/src/StackExchange.Redis/Message.ValueCondition.cs +++ b/src/StackExchange.Redis/Message.ValueCondition.cs @@ -1,6 +1,4 @@ -using System; - -namespace StackExchange.Redis; +namespace StackExchange.Redis; internal partial class Message { @@ -22,11 +20,11 @@ private sealed class KeyConditionMessage( public override int ArgCount => 1 + _when.TokenCount; - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, ArgCount); - physical.Write(Key); - _when.WriteTo(physical); + writer.WriteHeader(Command, ArgCount); + writer.Write(Key); + _when.WriteTo(writer); } } @@ -46,13 +44,13 @@ private sealed class KeyValueExpiryConditionMessage( public override int ArgCount => 2 + _expiry.TokenCount + _when.TokenCount; - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, ArgCount); - physical.Write(Key); - physical.WriteBulkString(_value); - _expiry.WriteTo(physical); - _when.WriteTo(physical); + writer.WriteHeader(Command, ArgCount); + writer.Write(Key); + writer.WriteBulkString(_value); + _expiry.WriteTo(writer); + _when.WriteTo(writer); } } } diff --git a/src/StackExchange.Redis/Message.cs b/src/StackExchange.Redis/Message.cs index 0ffcf4256..27da02aa0 100644 --- a/src/StackExchange.Redis/Message.cs +++ b/src/StackExchange.Redis/Message.cs @@ -7,8 +7,12 @@ using System.Text; using System.Threading; using Microsoft.Extensions.Logging; +using RESPite.Internal; +using RESPite.Messages; using StackExchange.Redis.Profiling; +#pragma warning disable SA1117 // params all same line; just noise here + namespace StackExchange.Redis { internal sealed class LoggingMessage : Message @@ -34,15 +38,12 @@ private LoggingMessage(ILogger log, Message tail) : base(tail.Db, tail.Flags, ta public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) => tail.GetHashSlot(serverSelectionStrategy); - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - try - { - var bridge = physical.BridgeCouldBeNull; - log?.LogTrace($"{bridge?.Name}: Writing: {tail.CommandAndKey}"); - } - catch { } - tail.WriteTo(physical); +#if VERBOSE + log?.LogTrace($"Writing: {tail.CommandAndKey}"); +#endif + tail.WriteTo(writer); } public override int ArgCount => tail.ArgCount; @@ -55,30 +56,34 @@ internal abstract partial class Message : ICompletable private uint _highIntegrityToken; - internal const CommandFlags InternalCallFlag = (CommandFlags)128; + internal const CommandFlags + InternalCallFlag = (CommandFlags)128, + NoFlushFlag = (CommandFlags)1024; protected RedisCommand command; private const CommandFlags AskingFlag = (CommandFlags)32, - ScriptUnavailableFlag = (CommandFlags)256, - DemandSubscriptionConnection = (CommandFlags)2048; + ScriptUnavailableFlag = (CommandFlags)256, + DemandSubscriptionConnection = (CommandFlags)2048; private const CommandFlags MaskPrimaryServerPreference = CommandFlags.DemandMaster - | CommandFlags.DemandReplica - | CommandFlags.PreferMaster - | CommandFlags.PreferReplica; + | CommandFlags.DemandReplica + | CommandFlags.PreferMaster + | CommandFlags.PreferReplica; private const CommandFlags UserSelectableFlags = CommandFlags.None - | CommandFlags.DemandMaster - | CommandFlags.DemandReplica - | CommandFlags.PreferMaster - | CommandFlags.PreferReplica + | CommandFlags.DemandMaster + | CommandFlags.DemandReplica + | CommandFlags.PreferMaster + | CommandFlags.PreferReplica #pragma warning disable CS0618 // Type or member is obsolete - | CommandFlags.HighPriority + | CommandFlags.HighPriority #pragma warning restore CS0618 - | CommandFlags.FireAndForget - | CommandFlags.NoRedirect - | CommandFlags.NoScriptCache; + | CommandFlags.FireAndForget + | CommandFlags.NoRedirect + | CommandFlags.NoScriptCache + | NoFlushFlag; // we'll allow this one even though not advertised + private IResultBox? resultBox; private ResultProcessor? resultProcessor; @@ -230,37 +235,46 @@ public static Message Create(int db, CommandFlags flags, RedisCommand command) public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key) => new CommandKeyMessage(db, flags, command, key); - public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key0, in RedisKey key1) => + public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key0, + in RedisKey key1) => new CommandKeyKeyMessage(db, flags, command, key0, key1); - public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key0, in RedisKey key1, in RedisValue value) => + public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key0, + in RedisKey key1, in RedisValue value) => new CommandKeyKeyValueMessage(db, flags, command, key0, key1, value); - public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key0, in RedisKey key1, in RedisKey key2) => + public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key0, + in RedisKey key1, in RedisKey key2) => new CommandKeyKeyKeyMessage(db, flags, command, key0, key1, key2); public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisValue value) => new CommandValueMessage(db, flags, command, value); - public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, in RedisValue value) => + public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, + in RedisValue value) => new CommandKeyValueMessage(db, flags, command, key, value); public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisChannel channel) => new CommandChannelMessage(db, flags, command, channel); - public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisChannel channel, in RedisValue value) => + public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisChannel channel, + in RedisValue value) => new CommandChannelValueMessage(db, flags, command, channel, value); - public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisValue value, in RedisChannel channel) => + public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisValue value, + in RedisChannel channel) => new CommandValueChannelMessage(db, flags, command, value, channel); - public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, in RedisValue value0, in RedisValue value1) => + public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, + in RedisValue value0, in RedisValue value1) => new CommandKeyValueValueMessage(db, flags, command, key, value0, value1); - public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, in RedisValue value0, in RedisValue value1, in RedisValue value2) => + public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, + in RedisValue value0, in RedisValue value1, in RedisValue value2) => new CommandKeyValueValueValueMessage(db, flags, command, key, value0, value1, value2); - public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, GeoEntry[] values) + public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, + GeoEntry[] values) { #if NET ArgumentNullException.ThrowIfNull(values); @@ -271,11 +285,13 @@ public static Message Create(int db, CommandFlags flags, RedisCommand command, i { throw new ArgumentOutOfRangeException(nameof(values)); } + if (values.Length == 1) { var value = values[0]; return Create(db, flags, command, key, value.Longitude, value.Latitude, value.Member); } + var arr = new RedisValue[3 * values.Length]; int index = 0; foreach (var value in values) @@ -284,34 +300,50 @@ public static Message Create(int db, CommandFlags flags, RedisCommand command, i arr[index++] = value.Latitude; arr[index++] = value.Member; } + return new CommandKeyValuesMessage(db, flags, command, key, arr); } - public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, in RedisValue value0, in RedisValue value1, in RedisValue value2, in RedisValue value3) => + public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, + in RedisValue value0, in RedisValue value1, in RedisValue value2, in RedisValue value3) => new CommandKeyValueValueValueValueMessage(db, flags, command, key, value0, value1, value2, value3); - public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, in RedisValue value0, in RedisValue value1, in RedisValue value2, in RedisValue value3, in RedisValue value4) => - new CommandKeyValueValueValueValueValueMessage(db, flags, command, key, value0, value1, value2, value3, value4); - - public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, in RedisValue value0, in RedisValue value1, in RedisValue value2, in RedisValue value3, in RedisValue value4, in RedisValue value5) => - new CommandKeyValueValueValueValueValueValueMessage(db, flags, command, key, value0, value1, value2, value3, value4, value5); - - public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, in RedisValue value0, in RedisValue value1, in RedisValue value2, in RedisValue value3, in RedisValue value4, in RedisValue value5, in RedisValue value6) => - new CommandKeyValueValueValueValueValueValueValueMessage(db, flags, command, key, value0, value1, value2, value3, value4, value5, value6); - - public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisValue value0, in RedisValue value1) => + public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, + in RedisValue value0, in RedisValue value1, in RedisValue value2, in RedisValue value3, + in RedisValue value4) => + new CommandKeyValueValueValueValueValueMessage(db, flags, command, key, value0, value1, value2, value3, + value4); + + public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, + in RedisValue value0, in RedisValue value1, in RedisValue value2, in RedisValue value3, + in RedisValue value4, in RedisValue value5) => + new CommandKeyValueValueValueValueValueValueMessage(db, flags, command, key, value0, value1, value2, value3, + value4, value5); + + public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, + in RedisValue value0, in RedisValue value1, in RedisValue value2, in RedisValue value3, + in RedisValue value4, in RedisValue value5, in RedisValue value6) => + new CommandKeyValueValueValueValueValueValueValueMessage(db, flags, command, key, value0, value1, value2, + value3, value4, value5, value6); + + public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisValue value0, + in RedisValue value1) => new CommandValueValueMessage(db, flags, command, value0, value1); - public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisValue value, in RedisKey key) => + public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisValue value, + in RedisKey key) => new CommandValueKeyMessage(db, flags, command, value, key); - public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisValue value0, in RedisValue value1, in RedisValue value2) => + public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisValue value0, + in RedisValue value1, in RedisValue value2) => new CommandValueValueValueMessage(db, flags, command, value0, value1, value2); - public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisValue value0, in RedisValue value1, in RedisValue value2, in RedisValue value3, in RedisValue value4) => + public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisValue value0, + in RedisValue value1, in RedisValue value2, in RedisValue value3, in RedisValue value4) => new CommandValueValueValueValueValueMessage(db, flags, command, value0, value1, value2, value3, value4); - public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, in RedisValue value0, in RedisValue value1, in RedisValue[] values) => + public static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, + in RedisValue value0, in RedisValue value1, in RedisValue[] values) => new CommandKeyValueValueValuesMessage(db, flags, command, key, value0, value1, values); public static Message Create( @@ -345,7 +377,8 @@ public static Message Create( in RedisValue value1, in RedisValue value2, in RedisValue value3) => - new CommandKeyKeyValueValueValueValueMessage(db, flags, command, key0, key1, value0, value1, value2, value3); + new CommandKeyKeyValueValueValueValueMessage(db, flags, command, key0, key1, value0, value1, value2, + value3); public static Message Create( int db, @@ -358,7 +391,8 @@ public static Message Create( in RedisValue value2, in RedisValue value3, in RedisValue value4) => - new CommandKeyKeyValueValueValueValueValueMessage(db, flags, command, key0, key1, value0, value1, value2, value3, value4); + new CommandKeyKeyValueValueValueValueValueMessage(db, flags, command, key0, key1, value0, value1, value2, + value3, value4); public static Message Create( int db, @@ -372,7 +406,8 @@ public static Message Create( in RedisValue value3, in RedisValue value4, in RedisValue value5) => - new CommandKeyKeyValueValueValueValueValueValueMessage(db, flags, command, key0, key1, value0, value1, value2, value3, value4, value5); + new CommandKeyKeyValueValueValueValueValueValueMessage(db, flags, command, key0, key1, value0, value1, + value2, value3, value4, value5); public static Message Create( int db, @@ -387,12 +422,15 @@ public static Message Create( in RedisValue value4, in RedisValue value5, in RedisValue value6) => - new CommandKeyKeyValueValueValueValueValueValueValueMessage(db, flags, command, key0, key1, value0, value1, value2, value3, value4, value5, value6); + new CommandKeyKeyValueValueValueValueValueValueValueMessage(db, flags, command, key0, key1, value0, value1, + value2, value3, value4, value5, value6); - public static Message CreateInSlot(int db, int slot, CommandFlags flags, RedisCommand command, RedisValue[] values) => + public static Message CreateInSlot(int db, int slot, CommandFlags flags, RedisCommand command, + RedisValue[] values) => new CommandSlotValuesMessage(db, slot, flags, command, values); - public static Message Create(int db, CommandFlags flags, RedisCommand command, KeyValuePair[] values, Expiration expiry, When when) + public static Message Create(int db, CommandFlags flags, RedisCommand command, + KeyValuePair[] values, Expiration expiry, When when) => new MultiSetMessage(db, flags, command, values, expiry, when); /// Gets whether this is primary-only. @@ -409,7 +447,8 @@ public virtual void AppendStormLog(StringBuilder sb) sb.Append(CommandAndKey); } - public virtual int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) => ServerSelectionStrategy.NoSlot; + public virtual int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) => + ServerSelectionStrategy.NoSlot; /// /// This does a few important things: @@ -455,7 +494,8 @@ public void Complete() internal bool ResultBoxIsAsync => Volatile.Read(ref resultBox)?.IsAsync == true; - internal static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, RedisKey[] keys) => keys.Length switch + internal static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, + RedisKey[] keys) => keys.Length switch { 0 => new CommandKeyMessage(db, flags, command, key), 1 => new CommandKeyKeyMessage(db, flags, command, key, keys[0]), @@ -463,27 +503,31 @@ public void Complete() _ => new CommandKeyKeysMessage(db, flags, command, key, keys), }; - internal static Message Create(int db, CommandFlags flags, RedisCommand command, IList keys) => keys.Count switch - { - 0 => new CommandMessage(db, flags, command), - 1 => new CommandKeyMessage(db, flags, command, keys[0]), - 2 => new CommandKeyKeyMessage(db, flags, command, keys[0], keys[1]), - 3 => new CommandKeyKeyKeyMessage(db, flags, command, keys[0], keys[1], keys[2]), - _ => new CommandKeysMessage(db, flags, command, (keys as RedisKey[]) ?? keys.ToArray()), - }; + internal static Message Create(int db, CommandFlags flags, RedisCommand command, IList keys) => + keys.Count switch + { + 0 => new CommandMessage(db, flags, command), + 1 => new CommandKeyMessage(db, flags, command, keys[0]), + 2 => new CommandKeyKeyMessage(db, flags, command, keys[0], keys[1]), + 3 => new CommandKeyKeyKeyMessage(db, flags, command, keys[0], keys[1], keys[2]), + _ => new CommandKeysMessage(db, flags, command, (keys as RedisKey[]) ?? keys.ToArray()), + }; - internal static Message Create(int db, CommandFlags flags, RedisCommand command, IList values) => values.Count switch - { - 0 => new CommandMessage(db, flags, command), - 1 => new CommandValueMessage(db, flags, command, values[0]), - 2 => new CommandValueValueMessage(db, flags, command, values[0], values[1]), - 3 => new CommandValueValueValueMessage(db, flags, command, values[0], values[1], values[2]), - // no 4; not worth adding - 5 => new CommandValueValueValueValueValueMessage(db, flags, command, values[0], values[1], values[2], values[3], values[4]), - _ => new CommandValuesMessage(db, flags, command, (values as RedisValue[]) ?? values.ToArray()), - }; + internal static Message Create(int db, CommandFlags flags, RedisCommand command, IList values) => + values.Count switch + { + 0 => new CommandMessage(db, flags, command), + 1 => new CommandValueMessage(db, flags, command, values[0]), + 2 => new CommandValueValueMessage(db, flags, command, values[0], values[1]), + 3 => new CommandValueValueValueMessage(db, flags, command, values[0], values[1], values[2]), + // no 4; not worth adding + 5 => new CommandValueValueValueValueValueMessage(db, flags, command, values[0], values[1], values[2], + values[3], values[4]), + _ => new CommandValuesMessage(db, flags, command, (values as RedisValue[]) ?? values.ToArray()), + }; - internal static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, RedisValue[] values) + internal static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key, + RedisValue[] values) { #if NET ArgumentNullException.ThrowIfNull(values); @@ -496,12 +540,14 @@ internal static Message Create(int db, CommandFlags flags, RedisCommand command, 1 => new CommandKeyValueMessage(db, flags, command, key, values[0]), 2 => new CommandKeyValueValueMessage(db, flags, command, key, values[0], values[1]), 3 => new CommandKeyValueValueValueMessage(db, flags, command, key, values[0], values[1], values[2]), - 4 => new CommandKeyValueValueValueValueMessage(db, flags, command, key, values[0], values[1], values[2], values[3]), + 4 => new CommandKeyValueValueValueValueMessage(db, flags, command, key, values[0], values[1], values[2], + values[3]), _ => new CommandKeyValuesMessage(db, flags, command, key, values), }; } - internal static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key0, in RedisKey key1, RedisValue[] values) + internal static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key0, + in RedisKey key1, RedisValue[] values) { #if NET ArgumentNullException.ThrowIfNull(values); @@ -513,16 +559,22 @@ internal static Message Create(int db, CommandFlags flags, RedisCommand command, 0 => new CommandKeyKeyMessage(db, flags, command, key0, key1), 1 => new CommandKeyKeyValueMessage(db, flags, command, key0, key1, values[0]), 2 => new CommandKeyKeyValueValueMessage(db, flags, command, key0, key1, values[0], values[1]), - 3 => new CommandKeyKeyValueValueValueMessage(db, flags, command, key0, key1, values[0], values[1], values[2]), - 4 => new CommandKeyKeyValueValueValueValueMessage(db, flags, command, key0, key1, values[0], values[1], values[2], values[3]), - 5 => new CommandKeyKeyValueValueValueValueValueMessage(db, flags, command, key0, key1, values[0], values[1], values[2], values[3], values[4]), - 6 => new CommandKeyKeyValueValueValueValueValueValueMessage(db, flags, command, key0, key1, values[0], values[1], values[2], values[3], values[4], values[5]), - 7 => new CommandKeyKeyValueValueValueValueValueValueValueMessage(db, flags, command, key0, key1, values[0], values[1], values[2], values[3], values[4], values[5], values[6]), + 3 => new CommandKeyKeyValueValueValueMessage(db, flags, command, key0, key1, values[0], values[1], + values[2]), + 4 => new CommandKeyKeyValueValueValueValueMessage(db, flags, command, key0, key1, values[0], values[1], + values[2], values[3]), + 5 => new CommandKeyKeyValueValueValueValueValueMessage(db, flags, command, key0, key1, values[0], + values[1], values[2], values[3], values[4]), + 6 => new CommandKeyKeyValueValueValueValueValueValueMessage(db, flags, command, key0, key1, values[0], + values[1], values[2], values[3], values[4], values[5]), + 7 => new CommandKeyKeyValueValueValueValueValueValueValueMessage(db, flags, command, key0, key1, + values[0], values[1], values[2], values[3], values[4], values[5], values[6]), _ => new CommandKeyKeyValuesMessage(db, flags, command, key0, key1, values), }; } - internal static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key0, RedisValue[] values, in RedisKey key1) + internal static Message Create(int db, CommandFlags flags, RedisCommand command, in RedisKey key0, + RedisValue[] values, in RedisKey key1) { #if NET ArgumentNullException.ThrowIfNull(values); @@ -594,34 +646,55 @@ internal static bool RequiresDatabase(RedisCommand command) internal static CommandFlags SetPrimaryReplicaFlags(CommandFlags everything, CommandFlags primaryReplica) { // take away the two flags we don't want, and add back the ones we care about - return (everything & ~(CommandFlags.DemandMaster | CommandFlags.DemandReplica | CommandFlags.PreferMaster | CommandFlags.PreferReplica)) - | primaryReplica; + return (everything & ~(CommandFlags.DemandMaster | CommandFlags.DemandReplica | CommandFlags.PreferMaster | + CommandFlags.PreferReplica)) + | primaryReplica; } internal void Cancel() => resultBox?.Cancel(); // true if ready to be completed (i.e. false if re-issued to another server) - internal bool ComputeResult(PhysicalConnection connection, in RawResult result) + internal bool ComputeResult(PhysicalConnection connection, ref RespReader reader) { + var prefix = RespPrefix.None; + + // intentionally "frame" is an isolated copy var box = resultBox; try { - if (box != null && box.IsFaulted) return false; // already failed (timeout, etc) - if (resultProcessor == null) return true; + // we don't want to mutate reader, so that processors can consume attributes; however, + // we also don't want to force the entire reader to copy each time, so: snapshot + // just the prefix + prefix = reader.GetFirstPrefix(); + + if (box != null && box.IsFaulted) + { + connection.OnDetailLog($"already faulted for {Command}"); + return false; // already failed (timeout, etc) + } + + if (resultProcessor == null) + { + connection.OnDetailLog($"no result processor for {Command}"); + return true; + } // false here would be things like resends (MOVED) - the message is not yet complete - return resultProcessor.SetResult(connection, this, result); + connection.OnDetailLog($"computing result for {Command} with {resultProcessor.GetType().Name}"); + return resultProcessor.SetResult(connection, this, ref reader); } catch (Exception ex) { - ex.Data.Add("got", result.ToString()); + connection.OnDetailLog($"{ex.GetType().Name}: {ex.Message}"); + ex.Data.Add("got", prefix.ToString()); connection?.BridgeCouldBeNull?.Multiplexer?.OnMessageFaulted(this, ex); box?.SetException(ex); return box != null; // we still want to pulse/complete } } - internal void Fail(ConnectionFailureType failure, Exception? innerException, string? annotation, ConnectionMultiplexer? muxer) + internal void Fail(ConnectionFailureType failure, Exception? innerException, string? annotation, + ConnectionMultiplexer? muxer) { PhysicalConnection.IdentifyFailureType(innerException, ref failure); resultProcessor?.ConnectionFail(this, failure, innerException, annotation, muxer); @@ -640,6 +713,7 @@ internal bool TrySetResult(T value) typed.SetResult(value); return true; } + return false; } @@ -707,8 +781,11 @@ internal void SetRequestSent() [MethodImpl(MethodImplOptions.AggressiveInlining)] internal void SetWriteTime() { - _writeTickCount = Environment.TickCount; // note this might be reset if we resend a message, cluster-moved etc; I'm OK with that + _writeTickCount = + Environment + .TickCount; // note this might be reset if we resend a message, cluster-moved etc; I'm OK with that } + private int _writeTickCount; public int GetWriteTime() => Volatile.Read(ref _writeTickCount); @@ -720,6 +797,11 @@ internal void SetWriteTime() public virtual string CommandString => Command.ToString(); + internal bool IsFlushRequiredAsync => (Flags & NoFlushFlag) == 0; + + // for sync to skip flush, we need *both* NoFlush and FireAndForget; we absolutely need to flush if someone is doing a sync call + internal bool IsFlushRequiredSync => (Flags & (NoFlushFlag | CommandFlags.FireAndForget)) != (NoFlushFlag | CommandFlags.FireAndForget); + /// /// Sends this command to the subscription connection rather than the interactive. /// @@ -775,13 +857,46 @@ internal void SetSource(IResultBox resultBox, ResultProcessor? resultPr this.resultProcessor = resultProcessor; } - protected abstract void WriteImpl(PhysicalConnection physical); + internal void WriteTo(in MessageWriter writer) => WriteImpl(in writer); + protected abstract void WriteImpl(in MessageWriter writer); + + internal string GetRespString(PhysicalConnection connection) + { + MessageWriter writer = new MessageWriter(connection, BlockBufferSerializer.Shared); + try + { + WriteImpl(in writer); + var bytes = MessageWriter.FlushBlockBuffer(); + string s = Encoding.UTF8.GetString(bytes.Span); + MessageWriter.ReleaseBlockBuffer(bytes); + return s; + } + finally + { + MessageWriter.RevertBlockBuffer(); + } + } internal void WriteTo(PhysicalConnection physical) { + MessageWriter writer = new MessageWriter(physical, physical.Output); try { - WriteImpl(physical); + WriteImpl(in writer); + } + catch (Exception ex) when (ex is not RedisCommandException) // these have specific meaning; don't wrap + { + physical?.OnInternalError(ex); + Fail(ConnectionFailureType.InternalFailure, ex, null, physical?.BridgeCouldBeNull?.Multiplexer); + } + } + + internal void WriteTo(PhysicalConnection physical, CommandMap commandMap, byte[]? channelPrefix) + { + MessageWriter writer = new MessageWriter(channelPrefix, commandMap, physical.Output); + try + { + WriteImpl(in writer); } catch (Exception ex) when (ex is not RedisCommandException) // these have specific meaning; don't wrap { @@ -795,15 +910,16 @@ internal void WriteTo(PhysicalConnection physical) internal void WriteHighIntegrityChecksumRequest(PhysicalConnection physical) { Debug.Assert(IsHighIntegrity, "should only be used for high-integrity"); + var writer = new MessageWriter(physical, physical.Output); try { - physical.WriteHeader(RedisCommand.ECHO, 1); // use WriteHeader to allow command-rewrite + writer.WriteHeader(RedisCommand.ECHO, 1); // use WriteHeader to allow command-rewrite Span chk = stackalloc byte[10]; Debug.Assert(ChecksumTemplate.Length == chk.Length, "checksum template length error"); ChecksumTemplate.CopyTo(chk); BinaryPrimitives.WriteUInt32LittleEndian(chk.Slice(4, 4), _highIntegrityToken); - physical.WriteRaw(chk); + writer.WriteRaw(chk); } catch (Exception ex) { @@ -841,20 +957,20 @@ public override int ArgCount return count; } } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, ArgCount); - physical.WriteBulkString(_protocolVersion); + writer.WriteHeader(Command, ArgCount); + writer.WriteBulkString(_protocolVersion); if (!string.IsNullOrWhiteSpace(_password)) { - physical.WriteBulkString("AUTH"u8); - physical.WriteBulkString(string.IsNullOrWhiteSpace(_username) ? RedisLiterals.@default : _username); - physical.WriteBulkString(_password); + writer.WriteBulkString("AUTH"u8); + writer.WriteBulkString(string.IsNullOrWhiteSpace(_username) ? RedisLiterals.@default : _username); + writer.WriteBulkString(_password); } if (!string.IsNullOrWhiteSpace(_clientName)) { - physical.WriteBulkString("SETNAME"u8); - physical.WriteBulkString(_clientName); + writer.WriteBulkString("SETNAME"u8); + writer.WriteBulkString(_clientName); } } } @@ -895,10 +1011,10 @@ private sealed class CommandChannelMessage : CommandChannelBase public CommandChannelMessage(int db, CommandFlags flags, RedisCommand command, in RedisChannel channel) : base(db, flags, command, channel) { } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, 1); - physical.Write(Channel); + writer.WriteHeader(Command, 1); + writer.Write(Channel); } public override int ArgCount => 1; } @@ -913,11 +1029,11 @@ public CommandChannelValueMessage(int db, CommandFlags flags, RedisCommand comma this.value = value; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, 2); - physical.Write(Channel); - physical.WriteBulkString(value); + writer.WriteHeader(Command, 2); + writer.Write(Channel); + writer.WriteBulkString(value); } public override int ArgCount => 2; } @@ -940,12 +1056,12 @@ public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) return serverSelectionStrategy.CombineSlot(slot, key2); } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, 3); - physical.Write(Key); - physical.Write(key1); - physical.Write(key2); + writer.WriteHeader(Command, 3); + writer.Write(Key); + writer.Write(key1); + writer.Write(key2); } public override int ArgCount => 3; } @@ -965,11 +1081,11 @@ public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) return serverSelectionStrategy.CombineSlot(slot, key1); } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, 2); - physical.Write(Key); - physical.Write(key1); + writer.WriteHeader(Command, 2); + writer.Write(Key); + writer.Write(key1); } public override int ArgCount => 2; } @@ -996,13 +1112,13 @@ public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) return slot; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(command, keys.Length + 1); - physical.Write(Key); + writer.WriteHeader(command, keys.Length + 1); + writer.Write(Key); for (int i = 0; i < keys.Length; i++) { - physical.Write(keys[i]); + writer.Write(keys[i]); } } public override int ArgCount => keys.Length + 1; @@ -1017,12 +1133,12 @@ public CommandKeyKeyValueMessage(int db, CommandFlags flags, RedisCommand comman this.value = value; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, 3); - physical.Write(Key); - physical.Write(key1); - physical.WriteBulkString(value); + writer.WriteHeader(Command, 3); + writer.Write(Key); + writer.Write(key1); + writer.WriteBulkString(value); } public override int ArgCount => 3; @@ -1032,10 +1148,10 @@ private sealed class CommandKeyMessage : CommandKeyBase { public CommandKeyMessage(int db, CommandFlags flags, RedisCommand command, in RedisKey key) : base(db, flags, command, key) { } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, 1); - physical.Write(Key); + writer.WriteHeader(Command, 1); + writer.Write(Key); } public override int ArgCount => 1; } @@ -1052,12 +1168,12 @@ public CommandValuesMessage(int db, CommandFlags flags, RedisCommand command, Re this.values = values; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(command, values.Length); + writer.WriteHeader(command, values.Length); for (int i = 0; i < values.Length; i++) { - physical.WriteBulkString(values[i]); + writer.WriteBulkString(values[i]); } } public override int ArgCount => values.Length; @@ -1085,12 +1201,12 @@ public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) return slot; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(command, keys.Length); + writer.WriteHeader(command, keys.Length); for (int i = 0; i < keys.Length; i++) { - physical.Write(keys[i]); + writer.Write(keys[i]); } } public override int ArgCount => keys.Length; @@ -1105,11 +1221,11 @@ public CommandKeyValueMessage(int db, CommandFlags flags, RedisCommand command, this.value = value; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, 2); - physical.Write(Key); - physical.WriteBulkString(value); + writer.WriteHeader(Command, 2); + writer.Write(Key); + writer.WriteBulkString(value); } public override int ArgCount => 2; } @@ -1135,12 +1251,12 @@ public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) return serverSelectionStrategy.CombineSlot(slot, key1); } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, values.Length + 2); - physical.Write(Key); - for (int i = 0; i < values.Length; i++) physical.WriteBulkString(values[i]); - physical.Write(key1); + writer.WriteHeader(Command, values.Length + 2); + writer.Write(Key); + for (int i = 0; i < values.Length; i++) writer.WriteBulkString(values[i]); + writer.Write(key1); } public override int ArgCount => values.Length + 2; } @@ -1157,11 +1273,11 @@ public CommandKeyValuesMessage(int db, CommandFlags flags, RedisCommand command, this.values = values; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, values.Length + 1); - physical.Write(Key); - for (int i = 0; i < values.Length; i++) physical.WriteBulkString(values[i]); + writer.WriteHeader(Command, values.Length + 1); + writer.Write(Key); + for (int i = 0; i < values.Length; i++) writer.WriteBulkString(values[i]); } public override int ArgCount => values.Length + 1; } @@ -1182,12 +1298,12 @@ public CommandKeyKeyValuesMessage(int db, CommandFlags flags, RedisCommand comma this.values = values; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, values.Length + 2); - physical.Write(Key); - physical.Write(key1); - for (int i = 0; i < values.Length; i++) physical.WriteBulkString(values[i]); + writer.WriteHeader(Command, values.Length + 2); + writer.Write(Key); + writer.Write(key1); + for (int i = 0; i < values.Length; i++) writer.WriteBulkString(values[i]); } public override int ArgCount => values.Length + 1; } @@ -1211,13 +1327,13 @@ public CommandKeyValueValueValuesMessage(int db, CommandFlags flags, RedisComman this.values = values; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, values.Length + 3); - physical.Write(Key); - physical.WriteBulkString(value0); - physical.WriteBulkString(value1); - for (int i = 0; i < values.Length; i++) physical.WriteBulkString(values[i]); + writer.WriteHeader(Command, values.Length + 3); + writer.Write(Key); + writer.WriteBulkString(value0); + writer.WriteBulkString(value1); + for (int i = 0; i < values.Length; i++) writer.WriteBulkString(values[i]); } public override int ArgCount => values.Length + 3; } @@ -1233,12 +1349,12 @@ public CommandKeyValueValueMessage(int db, CommandFlags flags, RedisCommand comm this.value1 = value1; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, 3); - physical.Write(Key); - physical.WriteBulkString(value0); - physical.WriteBulkString(value1); + writer.WriteHeader(Command, 3); + writer.Write(Key); + writer.WriteBulkString(value0); + writer.WriteBulkString(value1); } public override int ArgCount => 3; } @@ -1256,13 +1372,13 @@ public CommandKeyValueValueValueMessage(int db, CommandFlags flags, RedisCommand this.value2 = value2; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, 4); - physical.Write(Key); - physical.WriteBulkString(value0); - physical.WriteBulkString(value1); - physical.WriteBulkString(value2); + writer.WriteHeader(Command, 4); + writer.Write(Key); + writer.WriteBulkString(value0); + writer.WriteBulkString(value1); + writer.WriteBulkString(value2); } public override int ArgCount => 4; } @@ -1282,14 +1398,14 @@ public CommandKeyValueValueValueValueMessage(int db, CommandFlags flags, RedisCo this.value3 = value3; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, 5); - physical.Write(Key); - physical.WriteBulkString(value0); - physical.WriteBulkString(value1); - physical.WriteBulkString(value2); - physical.WriteBulkString(value3); + writer.WriteHeader(Command, 5); + writer.Write(Key); + writer.WriteBulkString(value0); + writer.WriteBulkString(value1); + writer.WriteBulkString(value2); + writer.WriteBulkString(value3); } public override int ArgCount => 5; } @@ -1311,15 +1427,15 @@ public CommandKeyValueValueValueValueValueMessage(int db, CommandFlags flags, Re this.value4 = value4; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, 6); - physical.Write(Key); - physical.WriteBulkString(value0); - physical.WriteBulkString(value1); - physical.WriteBulkString(value2); - physical.WriteBulkString(value3); - physical.WriteBulkString(value4); + writer.WriteHeader(Command, 6); + writer.Write(Key); + writer.WriteBulkString(value0); + writer.WriteBulkString(value1); + writer.WriteBulkString(value2); + writer.WriteBulkString(value3); + writer.WriteBulkString(value4); } public override int ArgCount => 6; } @@ -1344,16 +1460,16 @@ public CommandKeyValueValueValueValueValueValueMessage(int db, CommandFlags flag this.value5 = value5; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, ArgCount); - physical.Write(Key); - physical.WriteBulkString(value0); - physical.WriteBulkString(value1); - physical.WriteBulkString(value2); - physical.WriteBulkString(value3); - physical.WriteBulkString(value4); - physical.WriteBulkString(value5); + writer.WriteHeader(Command, ArgCount); + writer.Write(Key); + writer.WriteBulkString(value0); + writer.WriteBulkString(value1); + writer.WriteBulkString(value2); + writer.WriteBulkString(value3); + writer.WriteBulkString(value4); + writer.WriteBulkString(value5); } public override int ArgCount => 7; } @@ -1380,17 +1496,17 @@ public CommandKeyValueValueValueValueValueValueValueMessage(int db, CommandFlags this.value6 = value6; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, ArgCount); - physical.Write(Key); - physical.WriteBulkString(value0); - physical.WriteBulkString(value1); - physical.WriteBulkString(value2); - physical.WriteBulkString(value3); - physical.WriteBulkString(value4); - physical.WriteBulkString(value5); - physical.WriteBulkString(value6); + writer.WriteHeader(Command, ArgCount); + writer.Write(Key); + writer.WriteBulkString(value0); + writer.WriteBulkString(value1); + writer.WriteBulkString(value2); + writer.WriteBulkString(value3); + writer.WriteBulkString(value4); + writer.WriteBulkString(value5); + writer.WriteBulkString(value6); } public override int ArgCount => 8; } @@ -1417,13 +1533,13 @@ public CommandKeyKeyValueValueMessage( this.value1 = value1; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, ArgCount); - physical.Write(Key); - physical.Write(key1); - physical.WriteBulkString(value0); - physical.WriteBulkString(value1); + writer.WriteHeader(Command, ArgCount); + writer.Write(Key); + writer.Write(key1); + writer.WriteBulkString(value0); + writer.WriteBulkString(value1); } public override int ArgCount => 4; @@ -1454,14 +1570,14 @@ public CommandKeyKeyValueValueValueMessage( this.value2 = value2; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, ArgCount); - physical.Write(Key); - physical.Write(key1); - physical.WriteBulkString(value0); - physical.WriteBulkString(value1); - physical.WriteBulkString(value2); + writer.WriteHeader(Command, ArgCount); + writer.Write(Key); + writer.Write(key1); + writer.WriteBulkString(value0); + writer.WriteBulkString(value1); + writer.WriteBulkString(value2); } public override int ArgCount => 5; @@ -1495,15 +1611,15 @@ public CommandKeyKeyValueValueValueValueMessage( this.value3 = value3; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, ArgCount); - physical.Write(Key); - physical.Write(key1); - physical.WriteBulkString(value0); - physical.WriteBulkString(value1); - physical.WriteBulkString(value2); - physical.WriteBulkString(value3); + writer.WriteHeader(Command, ArgCount); + writer.Write(Key); + writer.Write(key1); + writer.WriteBulkString(value0); + writer.WriteBulkString(value1); + writer.WriteBulkString(value2); + writer.WriteBulkString(value3); } public override int ArgCount => 6; @@ -1540,16 +1656,16 @@ public CommandKeyKeyValueValueValueValueValueMessage( this.value4 = value4; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, ArgCount); - physical.Write(Key); - physical.Write(key1); - physical.WriteBulkString(value0); - physical.WriteBulkString(value1); - physical.WriteBulkString(value2); - physical.WriteBulkString(value3); - physical.WriteBulkString(value4); + writer.WriteHeader(Command, ArgCount); + writer.Write(Key); + writer.Write(key1); + writer.WriteBulkString(value0); + writer.WriteBulkString(value1); + writer.WriteBulkString(value2); + writer.WriteBulkString(value3); + writer.WriteBulkString(value4); } public override int ArgCount => 7; @@ -1589,17 +1705,17 @@ public CommandKeyKeyValueValueValueValueValueValueMessage( this.value5 = value5; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, ArgCount); - physical.Write(Key); - physical.Write(key1); - physical.WriteBulkString(value0); - physical.WriteBulkString(value1); - physical.WriteBulkString(value2); - physical.WriteBulkString(value3); - physical.WriteBulkString(value4); - physical.WriteBulkString(value5); + writer.WriteHeader(Command, ArgCount); + writer.Write(Key); + writer.Write(key1); + writer.WriteBulkString(value0); + writer.WriteBulkString(value1); + writer.WriteBulkString(value2); + writer.WriteBulkString(value3); + writer.WriteBulkString(value4); + writer.WriteBulkString(value5); } public override int ArgCount => 8; @@ -1642,18 +1758,18 @@ public CommandKeyKeyValueValueValueValueValueValueValueMessage( this.value6 = value6; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, ArgCount); - physical.Write(Key); - physical.Write(key1); - physical.WriteBulkString(value0); - physical.WriteBulkString(value1); - physical.WriteBulkString(value2); - physical.WriteBulkString(value3); - physical.WriteBulkString(value4); - physical.WriteBulkString(value5); - physical.WriteBulkString(value6); + writer.WriteHeader(Command, ArgCount); + writer.Write(Key); + writer.Write(key1); + writer.WriteBulkString(value0); + writer.WriteBulkString(value1); + writer.WriteBulkString(value2); + writer.WriteBulkString(value3); + writer.WriteBulkString(value4); + writer.WriteBulkString(value5); + writer.WriteBulkString(value6); } public override int ArgCount => 9; @@ -1662,9 +1778,9 @@ protected override void WriteImpl(PhysicalConnection physical) private sealed class CommandMessage : Message { public CommandMessage(int db, CommandFlags flags, RedisCommand command) : base(db, flags, command) { } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, 0); + writer.WriteHeader(Command, 0); } public override int ArgCount => 0; } @@ -1687,12 +1803,12 @@ public CommandSlotValuesMessage(int db, int slot, CommandFlags flags, RedisComma public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) => slot; - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(command, values.Length); + writer.WriteHeader(command, values.Length); for (int i = 0; i < values.Length; i++) { - physical.WriteBulkString(values[i]); + writer.WriteBulkString(values[i]); } } public override int ArgCount => values.Length; @@ -1718,29 +1834,29 @@ public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) ? (1 + (2 * values.Length) + expiry.TokenCount + (when is When.Exists or When.NotExists ? 1 : 0)) : (2 * values.Length); // MSET/MSETNX only support simple syntax - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { var cmd = Command; - physical.WriteHeader(cmd, ArgCount); + writer.WriteHeader(cmd, ArgCount); if (cmd == RedisCommand.MSETEX) // need count prefix { - physical.WriteBulkString(values.Length); + writer.WriteBulkString(values.Length); } for (int i = 0; i < values.Length; i++) { - physical.Write(values[i].Key); - physical.WriteBulkString(values[i].Value); + writer.Write(values[i].Key); + writer.WriteBulkString(values[i].Value); } if (cmd == RedisCommand.MSETEX) // allow expiry/mode tokens { - expiry.WriteTo(physical); + expiry.WriteTo(writer); switch (when) { case When.Exists: - physical.WriteBulkString("XX"u8); + writer.WriteBulkString("XX"u8); break; case When.NotExists: - physical.WriteBulkString("NX"u8); + writer.WriteBulkString("NX"u8); break; } } @@ -1757,11 +1873,11 @@ public CommandValueChannelMessage(int db, CommandFlags flags, RedisCommand comma this.value = value; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, 2); - physical.WriteBulkString(value); - physical.Write(Channel); + writer.WriteHeader(Command, 2); + writer.WriteBulkString(value); + writer.Write(Channel); } public override int ArgCount => 2; } @@ -1782,11 +1898,11 @@ public override void AppendStormLog(StringBuilder sb) sb.Append(" (").Append((string?)value).Append(')'); } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, 2); - physical.WriteBulkString(value); - physical.Write(Key); + writer.WriteHeader(Command, 2); + writer.WriteBulkString(value); + writer.Write(Key); } public override int ArgCount => 2; } @@ -1800,10 +1916,10 @@ public CommandValueMessage(int db, CommandFlags flags, RedisCommand command, in this.value = value; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, 1); - physical.WriteBulkString(value); + writer.WriteHeader(Command, 1); + writer.WriteBulkString(value); } public override int ArgCount => 1; } @@ -1819,11 +1935,11 @@ public CommandValueValueMessage(int db, CommandFlags flags, RedisCommand command this.value1 = value1; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, 2); - physical.WriteBulkString(value0); - physical.WriteBulkString(value1); + writer.WriteHeader(Command, 2); + writer.WriteBulkString(value0); + writer.WriteBulkString(value1); } public override int ArgCount => 2; } @@ -1841,12 +1957,12 @@ public CommandValueValueValueMessage(int db, CommandFlags flags, RedisCommand co this.value2 = value2; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, 3); - physical.WriteBulkString(value0); - physical.WriteBulkString(value1); - physical.WriteBulkString(value2); + writer.WriteHeader(Command, 3); + writer.WriteBulkString(value0); + writer.WriteBulkString(value1); + writer.WriteBulkString(value2); } public override int ArgCount => 3; } @@ -1868,14 +1984,14 @@ public CommandValueValueValueValueValueMessage(int db, CommandFlags flags, Redis this.value4 = value4; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, 5); - physical.WriteBulkString(value0); - physical.WriteBulkString(value1); - physical.WriteBulkString(value2); - physical.WriteBulkString(value3); - physical.WriteBulkString(value4); + writer.WriteHeader(Command, 5); + writer.WriteBulkString(value0); + writer.WriteBulkString(value1); + writer.WriteBulkString(value2); + writer.WriteBulkString(value3); + writer.WriteBulkString(value4); } public override int ArgCount => 5; } @@ -1886,10 +2002,10 @@ public SelectMessage(int db, CommandFlags flags) : base(db, flags, RedisCommand. { } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, 1); - physical.WriteBulkString(Db); + writer.WriteHeader(Command, 1); + writer.WriteBulkString(Db); } public override int ArgCount => 1; } @@ -1901,7 +2017,9 @@ internal sealed class UnknownMessage : Message public static UnknownMessage Instance { get; } = new(); private UnknownMessage() : base(0, CommandFlags.None, RedisCommand.UNKNOWN) { } public override int ArgCount => 0; - protected override void WriteImpl(PhysicalConnection physical) => throw new InvalidOperationException("This message cannot be written"); + protected override void WriteImpl(in MessageWriter writer) => throw new InvalidOperationException("This message cannot be written"); } + + public void SetNoFlush() => Flags |= NoFlushFlag; } } diff --git a/src/StackExchange.Redis/MessageWriter.cs b/src/StackExchange.Redis/MessageWriter.cs new file mode 100644 index 000000000..38269feef --- /dev/null +++ b/src/StackExchange.Redis/MessageWriter.cs @@ -0,0 +1,602 @@ +using System; +using System.Buffers; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using RESPite; +using RESPite.Internal; +using RESPite.Messages; + +namespace StackExchange.Redis; + +internal readonly ref struct MessageWriter +{ + private readonly CommandMap _map; + private readonly byte[]? _channelPrefix; + + public MessageWriter(byte[]? channelPrefix, CommandMap? map, IBufferWriter writer) + { + // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract + _map = map ?? CommandMap.Default; + _channelPrefix = channelPrefix; + _writer = writer; + } + + public static IBufferWriter BlockBuffer => BlockBufferSerializer.Shared; + + public MessageWriter(PhysicalConnection connection, IBufferWriter writer) + { + if (connection.BridgeCouldBeNull is { } bridge) + { + _map = bridge.Multiplexer.CommandMap; + _channelPrefix = connection.ChannelPrefix; + } + else + { + _map = CommandMap.Default; + _channelPrefix = null; + } + + _writer = writer ?? connection.Output; + } + + private readonly IBufferWriter _writer; + + public static ReadOnlyMemory FlushBlockBuffer() => + BlockBufferSerializer.BlockBuffer.FinalizeMessage(BlockBufferSerializer.Shared); + + public static void RevertBlockBuffer() => BlockBufferSerializer.Shared.Revert(); + + public static void ReleaseBlockBuffer(ReadOnlyMemory memory) + { + if (MemoryMarshal.TryGetMemoryManager( + memory, out var block)) + { + block.Release(); + } + } + + public static void ReleaseBlockBuffer(in ReadOnlySequence request) => + BlockBufferSerializer.BlockBuffer.Release(in request); + + public void Write(in RedisKey key) + { + var val = key.KeyValue; + if (val is string s) + { + WriteUnifiedPrefixedString(_writer, key.KeyPrefix, s); + } + else + { + WriteUnifiedPrefixedBlob(_writer, key.KeyPrefix, (byte[]?)val); + } + } + + internal void Write(in RedisChannel channel) + => WriteUnifiedPrefixedBlob(_writer, channel.IgnoreChannelPrefix ? null : _channelPrefix, channel.Value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void WriteBulkString(in RedisValue value) + => WriteBulkString(value, _writer); + + internal static void WriteBulkString(in RedisValue value, IBufferWriter writer) + { + switch (value.Type) + { + case RedisValue.StorageType.Null: + WriteUnifiedBlob(writer, (byte[]?)null); + break; + case RedisValue.StorageType.Int64: + WriteUnifiedInt64(writer, value.OverlappedValueInt64); + break; + case RedisValue.StorageType.UInt64: + WriteUnifiedUInt64(writer, value.OverlappedValueUInt64); + break; + case RedisValue.StorageType.Double: + WriteUnifiedDouble(writer, value.OverlappedValueDouble); + break; + case RedisValue.StorageType.String: + WriteUnifiedPrefixedString(writer, null, value.RawString()); + break; + case RedisValue.StorageType.MemoryManager or RedisValue.StorageType.ByteArray: + WriteUnifiedSpan(writer, value.RawSpan()); + break; + default: + throw new InvalidOperationException($"Unexpected {value.Type} value: '{value}'"); + } + } + + internal void WriteBulkString(ReadOnlySpan value) => WriteUnifiedSpan(_writer, value); + + internal const int + REDIS_MAX_ARGS = + 1024 * 1024; // there is a <= 1024*1024 max constraint inside redis itself: https://github.com/antirez/redis/blob/6c60526db91e23fb2d666fc52facc9a11780a2a3/src/networking.c#L1024 + + internal void WriteHeader(string command, int arguments) + { + byte[]? lease = null; + try + { + int bytes = Encoding.ASCII.GetMaxByteCount(command.Length); + Span buffer = command.Length <= 32 ? stackalloc byte[32] : (lease = ArrayPool.Shared.Rent(bytes)); + bytes = Encoding.ASCII.GetBytes(command, buffer); + var span = buffer.Slice(0, bytes); + AsciiHash.ToUpper(span); + WriteHeader(RedisCommand.UNKNOWN, arguments, span); + } + finally + { + if (lease is not null) ArrayPool.Shared.Return(lease); + } + } + + internal void WriteHeader(RedisCommand command, int arguments) => WriteHeader(command, arguments, _map.GetBytes(command).Span); + + internal void WriteHeader(RedisCommand command, int arguments, ReadOnlySpan commandBytes) + { + // using >= here because we will be adding 1 for the command itself (which is an arg for the purposes of the multi-bulk protocol) + if (arguments >= REDIS_MAX_ARGS) throw ExceptionFactory.TooManyArgs(command.ToString(), arguments); + + // in theory we should never see this; CheckMessage dealt with "regular" messages, and + // ExecuteMessage should have dealt with everything else + if (commandBytes.IsEmpty) throw ExceptionFactory.CommandDisabled(command); + + // *{argCount}\r\n = 3 + MaxInt32TextLen + // ${cmd-len}\r\n = 3 + MaxInt32TextLen + // {cmd}\r\n = 2 + commandBytes.Length + var span = _writer.GetSpan(commandBytes.Length + 8 + Format.MaxInt32TextLen + Format.MaxInt32TextLen); + span[0] = (byte)'*'; + + int offset = WriteRaw(span, arguments + 1, offset: 1); + span[offset++] = (byte)'$'; + offset = AppendToSpan(span, commandBytes, offset: offset); + + _writer.Advance(offset); + } + + internal static void WriteMultiBulkHeader(IBufferWriter writer, long count) + { + // *{count}\r\n = 3 + MaxInt32TextLen + var span = writer.GetSpan(3 + Format.MaxInt32TextLen); + span[0] = (byte)'*'; + int offset = WriteRaw(span, count, offset: 1); + writer.Advance(offset); + } + + internal static void WriteMultiBulkHeader(IBufferWriter writer, long count, RespPrefix prefix) + { + // *{count}\r\n = 3 + MaxInt32TextLen + var span = writer.GetSpan(3 + Format.MaxInt32TextLen); + span[0] = (byte)prefix; + if ((prefix is RespPrefix.Map or RespPrefix.Attribute) & count > 0) + { + if ((count & 1) != 0) Throw(prefix, count); + count >>= 1; + static void Throw(RespPrefix type, long count) => throw new ArgumentOutOfRangeException( + paramName: nameof(count), + message: $"{type} data must be in pairs; got {count}"); + } + int offset = WriteRaw(span, count, offset: 1); + writer.Advance(offset); + } + + private static ReadOnlySpan NullBulkString => "$-1\r\n"u8; + private static ReadOnlySpan EmptyBulkString => "$0\r\n\r\n"u8; + + internal static void WriteUnifiedPrefixedString(IBufferWriter writer, byte[]? prefix, string? value) + { + if (value == null) + { + // special case + writer.Write(NullBulkString); + } + else + { + // ${total-len}\r\n 3 + MaxInt32TextLen + // {prefix}{value}\r\n + int encodedLength = Encoding.UTF8.GetByteCount(value), + prefixLength = prefix?.Length ?? 0, + totalLength = prefixLength + encodedLength; + + if (totalLength == 0) + { + // special-case + writer.Write(EmptyBulkString); + } + else + { + var span = writer.GetSpan(3 + Format.MaxInt32TextLen); + span[0] = (byte)'$'; + int bytes = WriteRaw(span, totalLength, offset: 1); + writer.Advance(bytes); + + if (prefixLength != 0) writer.Write(prefix); + if (encodedLength != 0) WriteRaw(writer, value, encodedLength); + WriteCrlf(writer); + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static int WriteCrlf(Span span, int offset) + { + span[offset++] = (byte)'\r'; + span[offset++] = (byte)'\n'; + return offset; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static void WriteCrlf(IBufferWriter writer) + { + var span = writer.GetSpan(2); + span[0] = (byte)'\r'; + span[1] = (byte)'\n'; + writer.Advance(2); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void WriteRaw(ReadOnlySpan value) => _writer.Write(value); + + internal static int WriteRaw(Span span, long value, bool withLengthPrefix = false, int offset = 0) + { + if (value >= 0 && value <= 9) + { + if (withLengthPrefix) + { + span[offset++] = (byte)'1'; + offset = WriteCrlf(span, offset); + } + + span[offset++] = (byte)((int)'0' + (int)value); + } + else if (value >= 10 && value < 100) + { + if (withLengthPrefix) + { + span[offset++] = (byte)'2'; + offset = WriteCrlf(span, offset); + } + + span[offset++] = (byte)((int)'0' + ((int)value / 10)); + span[offset++] = (byte)((int)'0' + ((int)value % 10)); + } + else if (value >= 100 && value < 1000) + { + int v = (int)value; + int units = v % 10; + v /= 10; + int tens = v % 10, hundreds = v / 10; + if (withLengthPrefix) + { + span[offset++] = (byte)'3'; + offset = WriteCrlf(span, offset); + } + + span[offset++] = (byte)((int)'0' + hundreds); + span[offset++] = (byte)((int)'0' + tens); + span[offset++] = (byte)((int)'0' + units); + } + else if (value < 0 && value >= -9) + { + if (withLengthPrefix) + { + span[offset++] = (byte)'2'; + offset = WriteCrlf(span, offset); + } + + span[offset++] = (byte)'-'; + span[offset++] = (byte)((int)'0' - (int)value); + } + else if (value <= -10 && value > -100) + { + if (withLengthPrefix) + { + span[offset++] = (byte)'3'; + offset = WriteCrlf(span, offset); + } + + value = -value; + span[offset++] = (byte)'-'; + span[offset++] = (byte)((int)'0' + ((int)value / 10)); + span[offset++] = (byte)((int)'0' + ((int)value % 10)); + } + else + { + // we're going to write it, but *to the wrong place* + var availableChunk = span.Slice(offset); + var formattedLength = Format.FormatInt64(value, availableChunk); + if (withLengthPrefix) + { + // now we know how large the prefix is: write the prefix, then write the value + var prefixLength = Format.FormatInt32(formattedLength, availableChunk); + offset += prefixLength; + offset = WriteCrlf(span, offset); + + availableChunk = span.Slice(offset); + var finalLength = Format.FormatInt64(value, availableChunk); + offset += finalLength; + Debug.Assert(finalLength == formattedLength); + } + else + { + offset += formattedLength; + } + } + + return WriteCrlf(span, offset); + } + + [ThreadStatic] + private static Encoder? s_PerThreadEncoder; + + internal static Encoder GetPerThreadEncoder() + { + var encoder = s_PerThreadEncoder; + if (encoder == null) + { + s_PerThreadEncoder = encoder = Encoding.UTF8.GetEncoder(); + } + else + { + encoder.Reset(); + } + + return encoder; + } + + internal static unsafe void WriteRaw(IBufferWriter writer, string value, int expectedLength) + { + const int MaxQuickEncodeSize = 512; + + fixed (char* cPtr = value) + { + int totalBytes; + if (expectedLength <= MaxQuickEncodeSize) + { + // encode directly in one hit + var span = writer.GetSpan(expectedLength); + fixed (byte* bPtr = span) + { + totalBytes = Encoding.UTF8.GetBytes( + cPtr, + value.Length, + bPtr, + expectedLength); + } + + writer.Advance(expectedLength); + } + else + { + // use an encoder in a loop + var encoder = GetPerThreadEncoder(); + int charsRemaining = value.Length, charOffset = 0; + totalBytes = 0; + + bool final = false; + while (true) + { + var span = writer + .GetSpan(5); // get *some* memory - at least enough for 1 character (but hopefully lots more) + + int charsUsed, bytesUsed; + bool completed; + fixed (byte* bPtr = span) + { + encoder.Convert( + cPtr + charOffset, + charsRemaining, + bPtr, + span.Length, + final, + out charsUsed, + out bytesUsed, + out completed); + } + + writer.Advance(bytesUsed); + totalBytes += bytesUsed; + charOffset += charsUsed; + charsRemaining -= charsUsed; + + if (charsRemaining <= 0) + { + if (charsRemaining < 0) throw new InvalidOperationException("String encode went negative"); + if (completed) break; // fine + if (final) throw new InvalidOperationException("String encode failed to complete"); + final = true; // flush the encoder to one more span, then exit + } + } + } + + if (totalBytes != expectedLength) throw new InvalidOperationException("String encode length check failure"); + } + } + + private static void WriteUnifiedPrefixedBlob(IBufferWriter writer, byte[]? prefix, byte[]? value) + { + // ${total-len}\r\n + // {prefix}{value}\r\n + if (prefix == null || prefix.Length == 0 || value == null) + { + // if no prefix, just use the non-prefixed version; + // even if prefixed, a null value writes as null, so can use the non-prefixed version + WriteUnifiedBlob(writer, value); + } + else + { + var span = writer.GetSpan(3 + + Format + .MaxInt32TextLen); // note even with 2 max-len, we're still in same text range + span[0] = (byte)'$'; + int bytes = WriteRaw(span, prefix.LongLength + value.LongLength, offset: 1); + writer.Advance(bytes); + + writer.Write(prefix); + writer.Write(value); + + span = writer.GetSpan(2); + WriteCrlf(span, 0); + writer.Advance(2); + } + } + + 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" + + // ${asc-len}\r\n = 4/5 (asc-len at most 2 digits) + // {asc}\r\n = MaxInt64TextLen + 2 + var span = writer.GetSpan(7 + Format.MaxInt64TextLen); + + span[0] = (byte)'$'; + var bytes = WriteRaw(span, value, withLengthPrefix: true, offset: 1); + writer.Advance(bytes); + } + + 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" + Span valueSpan = stackalloc byte[Format.MaxInt64TextLen]; + + var len = Format.FormatUInt64(value, valueSpan); + // ${asc-len}\r\n = 4/5 (asc-len at most 2 digits) + // {asc}\r\n = {len} + 2 + var span = writer.GetSpan(7 + len); + span[0] = (byte)'$'; + int offset = WriteRaw(span, len, withLengthPrefix: false, offset: 1); + valueSpan.Slice(0, len).CopyTo(span.Slice(offset)); + offset += len; + offset = WriteCrlf(span, offset); + writer.Advance(offset); + } + + private static void WriteUnifiedDouble(IBufferWriter writer, double value) + { +#if NET8_0_OR_GREATER + Span valueSpan = stackalloc byte[Format.MaxDoubleTextLen]; + var len = Format.FormatDouble(value, valueSpan); + + // ${asc-len}\r\n = 4/5 (asc-len at most 2 digits) + // {asc}\r\n = {len} + 2 + var span = writer.GetSpan(7 + len); + span[0] = (byte)'$'; + int offset = WriteRaw(span, len, withLengthPrefix: false, offset: 1); + valueSpan.Slice(0, len).CopyTo(span.Slice(offset)); + offset += len; + offset = WriteCrlf(span, offset); + writer.Advance(offset); +#else + // fallback: drop to string + WriteUnifiedPrefixedString(writer, null, Format.ToString(value)); +#endif + } + + internal static void WriteInteger(IBufferWriter writer, long value) + { + // note: client should never write integer; only server does this + // :{asc}\r\n = MaxInt64TextLen + 3 + var span = writer.GetSpan(3 + Format.MaxInt64TextLen); + + span[0] = (byte)':'; + var bytes = WriteRaw(span, value, withLengthPrefix: false, offset: 1); + writer.Advance(bytes); + } + + private static void WriteUnifiedBlob(IBufferWriter writer, byte[]? value) + { + if (value is null) + { + // special case: + writer.Write(NullBulkString); + } + else + { + WriteUnifiedSpan(writer, new ReadOnlySpan(value)); + } + } + + private static void WriteUnifiedSpan(IBufferWriter writer, ReadOnlySpan value) + { + // ${len}\r\n = 3 + MaxInt32TextLen + // {value}\r\n = 2 + value.Length + const int MaxQuickSpanSize = 512; + if (value.Length == 0) + { + // special case: + writer.Write(EmptyBulkString); + } + else if (value.Length <= MaxQuickSpanSize) + { + var span = writer.GetSpan(5 + Format.MaxInt32TextLen + value.Length); + span[0] = (byte)'$'; + int bytes = AppendToSpan(span, value, 1); + writer.Advance(bytes); + } + else + { + // too big to guarantee can do in a single span + var span = writer.GetSpan(3 + Format.MaxInt32TextLen); + span[0] = (byte)'$'; + int bytes = WriteRaw(span, value.Length, offset: 1); + writer.Advance(bytes); + + writer.Write(value); + + WriteCrlf(writer); + } + } + + private static int AppendToSpan(Span span, ReadOnlySpan value, int offset = 0) + { + offset = WriteRaw(span, value.Length, offset: offset); + value.CopyTo(span.Slice(offset, value.Length)); + offset += value.Length; + return WriteCrlf(span, offset); + } + + internal void WriteSha1AsHex(byte[]? value) + { + var writer = _writer; + if (value is null) + { + writer.Write(NullBulkString); + } + else if (value.Length == ResultProcessor.ScriptLoadProcessor.Sha1HashLength) + { + // $40\r\n = 5 + // {40 bytes}\r\n = 42 + var span = writer.GetSpan(47); + span[0] = (byte)'$'; + span[1] = (byte)'4'; + span[2] = (byte)'0'; + span[3] = (byte)'\r'; + span[4] = (byte)'\n'; + + int offset = 5; + for (int i = 0; i < value.Length; i++) + { + var b = value[i]; + span[offset++] = ToHexNibble(b >> 4); + span[offset++] = ToHexNibble(b & 15); + } + + span[offset++] = (byte)'\r'; + span[offset++] = (byte)'\n'; + + writer.Advance(offset); + } + else + { + throw new InvalidOperationException("Invalid SHA1 length: " + value.Length); + } + } + + internal static byte ToHexNibble(int value) + { + return value < 10 ? (byte)('0' + value) : (byte)('a' - 10 + value); + } +} diff --git a/src/StackExchange.Redis/NullableHacks.cs b/src/StackExchange.Redis/NullableHacks.cs deleted file mode 100644 index 4ebebf73b..000000000 --- a/src/StackExchange.Redis/NullableHacks.cs +++ /dev/null @@ -1,148 +0,0 @@ -// 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 !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 { } - - /// 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 !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 - { - /// 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/StackExchange.Redis/PhysicalBridge.cs b/src/StackExchange.Redis/PhysicalBridge.cs index 36d8268bf..230083883 100644 --- a/src/StackExchange.Redis/PhysicalBridge.cs +++ b/src/StackExchange.Redis/PhysicalBridge.cs @@ -854,9 +854,9 @@ internal WriteResult WriteMessageTakingWriteLockSync(PhysicalConnection physical var result = WriteMessageInsideLock(physical, message); - if (result == WriteResult.Success) + if (result == WriteResult.Success & message.IsFlushRequiredSync) { - result = physical.FlushSync(false, TimeoutMilliseconds); + physical.Flush(); } physical.SetIdle(); @@ -1179,7 +1179,8 @@ private void ProcessBridgeBacklog() // Only execute if we're connected. // Timeouts are handled above, so we're exclusively into backlog items eligible to write at this point. // If we can't write them, abort and wait for the next heartbeat or activation to try this again. - while (IsConnected && physical?.HasOutputPipe == true) + bool flush = false; + while (IsConnected && physical is { HasOutputPipe: true }) { Message? message; _backlogStatus = BacklogStatus.CheckingForWork; @@ -1199,13 +1200,8 @@ private void ProcessBridgeBacklog() _backlogStatus = BacklogStatus.WritingMessage; var result = WriteMessageInsideLock(physical, message); - if (result == WriteResult.Success) - { - _backlogStatus = BacklogStatus.Flushing; -#pragma warning disable CS0618 // Type or member is obsolete - result = physical.FlushSync(false, TimeoutMilliseconds); -#pragma warning restore CS0618 // Type or member is obsolete - } + // use flush logic that assumes a sync caller + if (!flush) flush = message.IsFlushRequiredSync; _backlogStatus = BacklogStatus.MarkingInactive; if (result != WriteResult.Success) @@ -1225,6 +1221,14 @@ private void ProcessBridgeBacklog() UnmarkActiveMessage(message); } } + + if (flush && IsConnected && physical is { HasOutputPipe: true }) + { + // at least one message wants flushing + _backlogStatus = BacklogStatus.Flushing; + physical?.Flush(); + } + _backlogStatus = BacklogStatus.SettingIdle; physical?.SetIdle(); _backlogStatus = BacklogStatus.Inactive; @@ -1310,7 +1314,7 @@ internal ValueTask WriteMessageTakingWriteLockAsync(PhysicalConnect // an actual timeout #if NET var pending = _singleWriterMutex.WaitAsync(TimeoutMilliseconds); - if (pending.Status != TaskStatus.RanToCompletion) return WriteMessageTakingWriteLockAsync_Awaited(pending, physical, message); + if (pending.IsCompletedSuccessfully) return WriteMessageTakingWriteLockAsync_Awaited(pending, physical, message); gotLock = pending.Result; // fine since we know we got a result if (!gotLock) return new ValueTask(TimedOutBeforeWrite(message)); @@ -1323,20 +1327,9 @@ internal ValueTask WriteMessageTakingWriteLockAsync(PhysicalConnect #endif } var result = WriteMessageInsideLock(physical, message); - if (result == WriteResult.Success) + if (result == WriteResult.Success & message.IsFlushRequiredAsync) { - var flush = physical.FlushAsync(false); - if (!flush.IsCompletedSuccessfully) - { - releaseLock = false; // so we don't release prematurely -#if NET - return CompleteWriteAndReleaseLockAsync(flush, message); -#else - return CompleteWriteAndReleaseLockAsync(token, flush, message); -#endif - } - - result = flush.Result; // .Result: we know it was completed, so this is fine + physical.Flush(); } physical.SetIdle(); @@ -1394,7 +1387,7 @@ private async ValueTask WriteMessageTakingWriteLockAsync_Awaited( if (result == WriteResult.Success) { - result = await physical.FlushAsync(false).ForAwait(); + physical.Flush(); } physical.SetIdle(); @@ -1495,7 +1488,7 @@ private bool ChangeState(State oldState, State newState) Interlocked.Exchange(ref connectStartTicks, Environment.TickCount); // separate creation and connection for case when connection completes synchronously // in that case PhysicalConnection will call back to PhysicalBridge, and most PhysicalBridge methods assume that physical is not null; - physical = new PhysicalConnection(this); + physical = new PhysicalConnection(this, Multiplexer.RawConfig.WriteMode); physical.BeginConnectAsync(log).RedisFireAndForget(); } @@ -1715,6 +1708,8 @@ private uint NextHighIntegrityTokenInsideLock() } } + internal bool CanSimulateConnectionFailure => Multiplexer.RawConfig.AllowAdmin && physical?.CanSimulateConnectionFailure == true; + /// /// For testing only. /// diff --git a/src/StackExchange.Redis/PhysicalConnection.Read.cs b/src/StackExchange.Redis/PhysicalConnection.Read.cs new file mode 100644 index 000000000..a7870498d --- /dev/null +++ b/src/StackExchange.Redis/PhysicalConnection.Read.cs @@ -0,0 +1,790 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using RESPite; +using RESPite.Buffers; +using RESPite.Internal; +using RESPite.Messages; + +namespace StackExchange.Redis; + +internal sealed partial class PhysicalConnection +{ + private long totalBytesReceived; + + internal static PhysicalConnection Dummy(BufferedStreamWriter.WriteMode writeMode = BufferedStreamWriter.WriteMode.Default) + => new(null!, writeMode); + + private volatile ReadStatus _readStatus = ReadStatus.NotStarted; + internal ReadStatus GetReadStatus() => _readStatus; + + private BufferedStreamWriter.WriteMode WriteMode { get; } + + internal void StartReading(CancellationToken cancellation = default) + { + if (cancellation.CanBeCanceled && cancellation != InputCancel) + { + cancellation.ThrowIfCancellationRequested(); + if (InputCancel.CanBeCanceled) + { + cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellation, InputCancel).Token; + } + } + else + { + cancellation = InputCancel; + } + + if (_output is { IsSync: true }) + { + StartReadingSync(this, cancellation); + static void StartReadingSync(PhysicalConnection conn, CancellationToken cancellation) + { + // this method exists purely to limit capture context scope + Thread thread = new Thread(() => conn.ReadAllSync(cancellation)) + { + IsBackground = true, + Priority = ThreadPriority.AboveNormal, + Name = "SE.Redis Sync Writer", + }; + thread.Start(); + } + } + else + { + ReadAllAsync(cancellation).RedisFireAndForget(); + } + } + + private async Task ReadAllAsync(CancellationToken cancellationToken) + { + var tail = _ioStream ?? Stream.Null; + _readStatus = ReadStatus.Init; + _readState = default; + _readBuffer = CycleBuffer.Create(); + try + { + int read; + do + { + _readStatus = ReadStatus.ReadAsync; + var buffer = _readBuffer.GetUncommittedMemory(); + var pending = tail.ReadAsync(buffer, cancellationToken); +#if DEBUG + bool inline = pending.IsCompleted; +#endif + read = await pending.ConfigureAwait(false); + _readStatus = ReadStatus.UpdateWriteTime; + UpdateLastReadTime(); +#if DEBUG + DebugCounters.OnAsyncRead(read, inline); +#endif + _readStatus = ReadStatus.TryParseResult; + } + // another formatter glitch + while (CommitAndParseFrames(read) && !ForceReconnect); + + _readStatus = ReadStatus.ProcessBufferComplete; + + // Volatile.Write(ref _readStatus, ReaderCompleted); + _readBuffer.Release(); // clean exit, we can recycle + _readStatus = ReadStatus.RanToCompletion; + RecordConnectionFailed(ConnectionFailureType.SocketClosed); + } + catch (EndOfStreamException) when (_readStatus is ReadStatus.ReadAsync) + { + _readStatus = ReadStatus.RanToCompletion; + RecordConnectionFailed(ConnectionFailureType.SocketClosed); + } + catch (OperationCanceledException) when (_readStatus is ReadStatus.ReadAsync) + { + _readStatus = ReadStatus.RanToCompletion; + RecordConnectionFailed(ConnectionFailureType.SocketClosed); + } + catch (Exception ex) + { + _readStatus = ReadStatus.Faulted; + RecordConnectionFailed(ConnectionFailureType.InternalFailure, ex); + } + finally + { + _readBuffer = default; // wipe, however we exited + } + } + + private void ReadAllSync(CancellationToken cancellationToken) + { + var tail = _ioStream ?? Stream.Null; + _readStatus = ReadStatus.Init; + _readState = default; + _readBuffer = CycleBuffer.Create(); + try + { + int read; + do + { + _readStatus = ReadStatus.ReadAsync; + var buffer = _readBuffer.GetUncommittedMemory(); + cancellationToken.ThrowIfCancellationRequested(); +#if NET || NETSTANDARD2_1_OR_GREATER + read = tail.Read(buffer.Span); +#else + read = tail.Read(buffer); +#endif + + _readStatus = ReadStatus.UpdateWriteTime; + UpdateLastReadTime(); + + DebugCounters.OnSyncRead(read); + _readStatus = ReadStatus.TryParseResult; + } + // another formatter glitch + while (CommitAndParseFrames(read) && !ForceReconnect); + + _readStatus = ReadStatus.ProcessBufferComplete; + + // Volatile.Write(ref _readStatus, ReaderCompleted); + _readBuffer.Release(); // clean exit, we can recycle + _readStatus = ReadStatus.RanToCompletion; + RecordConnectionFailed(ConnectionFailureType.SocketClosed); + } + catch (EndOfStreamException) when (_readStatus is ReadStatus.ReadAsync) + { + _readStatus = ReadStatus.RanToCompletion; + RecordConnectionFailed(ConnectionFailureType.SocketClosed); + } + catch (OperationCanceledException) when (_readStatus is ReadStatus.ReadAsync) + { + _readStatus = ReadStatus.RanToCompletion; + RecordConnectionFailed(ConnectionFailureType.SocketClosed); + } + catch (Exception ex) + { + _readStatus = ReadStatus.Faulted; + RecordConnectionFailed(ConnectionFailureType.InternalFailure, ex); + } + finally + { + _readBuffer = default; // wipe, however we exited + } + } + + private bool ForceReconnect => BridgeCouldBeNull?.NeedsReconnect == true; + + private static byte[]? SharedNoLease; + + private CycleBuffer _readBuffer; + private RespScanState _readState = default; + + private long GetReadCommittedLength() + { + try + { + var len = _readBuffer.GetCommittedLength(); + return len < 0 ? -1 : len; + } + catch + { + return -1; + } + } + + private bool CommitAndParseFrames(int bytesRead) + { + if (bytesRead <= 0) + { + return false; + } + ref RespScanState state = ref _readState; // avoid a ton of ldarg0 + + totalBytesReceived += bytesRead; +#if PARSE_DETAIL + string src = $"parse {bytesRead}"; + try +#endif + { + Debug.Assert(_readBuffer.GetCommittedLength() >= 0, "multi-segment running-indices are corrupt"); +#if PARSE_DETAIL + src += $" ({_readBuffer.GetCommittedLength()}+{bytesRead}-{state.TotalBytes})"; +#endif + Debug.Assert( + bytesRead <= _readBuffer.UncommittedAvailable, + $"Insufficient bytes in {nameof(CommitAndParseFrames)}; got {bytesRead}, Available={_readBuffer.UncommittedAvailable}"); + _readBuffer.Commit(bytesRead); +#if PARSE_DETAIL + src += $",total {_readBuffer.GetCommittedLength()}"; +#endif + var scanner = RespFrameScanner.Default; + + OperationStatus status = OperationStatus.NeedMoreData; + if (_readBuffer.TryGetCommitted(out var fullSpan)) + { + int fullyConsumed = 0; + var toParse = fullSpan.Slice((int)state.TotalBytes); // skip what we've already parsed + OnDetailLog($"parsing {toParse.Length} bytes, single buffer"); + + Debug.Assert(!toParse.IsEmpty); + while (true) + { +#if PARSE_DETAIL + src += $",span {toParse.Length}"; +#endif + int totalBytesBefore = (int)state.TotalBytes; + if (toParse.Length < RespScanState.MinBytes + || (status = scanner.TryRead(ref state, toParse)) != OperationStatus.Done) + { + break; + } + + Debug.Assert( + state is + { + IsComplete: true, TotalBytes: >= RespScanState.MinBytes, Prefix: not RespPrefix.None + }, + "Invalid RESP read state"); + + // extract the frame + var bytes = (int)state.TotalBytes; +#if PARSE_DETAIL + src += $",frame {bytes}"; +#endif + // send the frame somewhere (note this is the *full* frame, not just the bit we just parsed) + OnDetailLog($"found {state.Prefix} frame, {bytes} bytes"); + OnResponseFrame(state.Prefix, fullSpan.Slice(fullyConsumed, bytes), ref SharedNoLease); + UpdateBufferStats(bytes, toParse.Length); + + // update our buffers to the unread potions and reset for a new RESP frame + fullyConsumed += bytes; + toParse = toParse.Slice(bytes - totalBytesBefore); // move past the extra bytes we just read + state = default; + status = OperationStatus.NeedMoreData; + } + OnDetailLog($"discarding {fullyConsumed} bytes"); + _readBuffer.DiscardCommitted(fullyConsumed); + } + else // the same thing again, but this time with multi-segment sequence + { + var fullSequence = _readBuffer.GetAllCommitted(); + Debug.Assert( + fullSequence is { IsEmpty: false, IsSingleSegment: false }, + "non-trivial sequence expected"); + + long fullyConsumed = 0; + var toParse = fullSequence.Slice((int)state.TotalBytes); // skip what we've already parsed + OnDetailLog($"parsing {toParse.Length} bytes, multi-buffer"); + + while (true) + { +#if PARSE_DETAIL + src += $",ros {toParse.Length}"; +#endif + int totalBytesBefore = (int)state.TotalBytes; + if (toParse.Length < RespScanState.MinBytes + || (status = scanner.TryRead(ref state, toParse)) != OperationStatus.Done) + { + break; + } + + Debug.Assert( + state is + { + IsComplete: true, TotalBytes: >= RespScanState.MinBytes, Prefix: not RespPrefix.None + }, + "Invalid RESP read state"); + + // extract the frame + var bytes = (int)state.TotalBytes; +#if PARSE_DETAIL + src += $",frame {bytes}"; +#endif + // send the frame somewhere (note this is the *full* frame, not just the bit we just parsed) + OnDetailLog($"found {state.Prefix} frame, {bytes} bytes"); + OnResponseFrame(state.Prefix, fullSequence.Slice(fullyConsumed, bytes)); + UpdateBufferStats(bytes, toParse.Length); + + // update our buffers to the unread potions and reset for a new RESP frame + fullyConsumed += bytes; + toParse = toParse.Slice(bytes - totalBytesBefore); // move past the extra bytes we just read + state = default; + status = OperationStatus.NeedMoreData; + } + + OnDetailLog($"discarding {fullyConsumed} bytes"); + _readBuffer.DiscardCommitted(fullyConsumed); + } + + if (status != OperationStatus.NeedMoreData) + { + ThrowStatus(status); + + static void ThrowStatus(OperationStatus status) => + throw new InvalidOperationException($"Unexpected operation status: {status}"); + } + + return true; + } +#if PARSE_DETAIL + catch (Exception ex) + { + OnDetailLog($"{nameof(CommitAndParseFrames)}: {ex.Message}"); + OnDetailLog(src); + if (Debugger.IsAttached) Debugger.Break(); + throw new InvalidOperationException($"{src} lead to {ex.Message}", ex); + } +#endif + } + + [Conditional("PARSE_DETAIL")] + internal void OnDetailLog(string message) + { +#if PARSE_DETAIL + message = $"[{id}] {message}"; + lock (LogLock) + { + Console.WriteLine(message); + Debug.WriteLine(message); + File.AppendAllText("verbose.log", message + Environment.NewLine); + } +#endif + } + +#if PARSE_DETAIL + private static int s_id; + private readonly int id = Interlocked.Increment(ref s_id); + private static readonly object LogLock = new(); +#endif + + private void OnResponseFrame(RespPrefix prefix, ReadOnlySequence payload) + { + if (payload.IsSingleSegment) + { +#if NETCOREAPP || NETSTANDARD2_1_OR_GREATER + OnResponseFrame(prefix, payload.FirstSpan, ref SharedNoLease); +#else + OnResponseFrame(prefix, payload.First.Span, ref SharedNoLease); +#endif + } + else + { + var len = checked((int)payload.Length); + byte[]? oversized = ArrayPool.Shared.Rent(len); + payload.CopyTo(oversized); + OnResponseFrame(prefix, new(oversized, 0, len), ref oversized); + + // the lease could have been claimed by the activation code (to prevent another memcpy); otherwise, free + if (oversized is not null) + { + ArrayPool.Shared.Return(oversized); + } + } + } + + private void UpdateBufferStats(int lastResult, long inBuffer) + { + // Track the last result size *after* processing for the *next* error message + bytesInBuffer = inBuffer; + bytesLastResult = lastResult; + } + + private void OnResponseFrame(RespPrefix prefix, ReadOnlySpan frame, ref byte[]? lease) + { + DebugValidateSingleFrame(frame); + _readStatus = ReadStatus.MatchResult; + + switch (prefix) + { + case RespPrefix.Push: // explicit RESP3 push message + case RespPrefix.Array when (_protocol is RedisProtocol.Resp2 & connectionType is ConnectionType.Subscription) + && !IsArrayPong(frame): // could be a RESP2 pub/sub payload + // out-of-band; pub/sub etc + if (OnOutOfBand(frame, ref lease)) + { + OnDetailLog($"out-of-band message, not dequeuing: {prefix}"); + return; + } + break; + } + + // request/response; match to inbound + MatchNextResult(frame); + + static bool IsArrayPong(ReadOnlySpan payload) + { + if (payload.Length >= sizeof(ulong)) + { + var hash = AsciiHash.HashCS(payload); + switch (hash) + { + case ArrayPong_LC_Bulk.HashCS when payload.StartsWith(ArrayPong_LC_Bulk.U8): + case ArrayPong_UC_Bulk.HashCS when payload.StartsWith(ArrayPong_UC_Bulk.U8): + case ArrayPong_LC_Simple.HashCS when payload.StartsWith(ArrayPong_LC_Simple.U8): + case ArrayPong_UC_Simple.HashCS when payload.StartsWith(ArrayPong_UC_Simple.U8): + var reader = new RespReader(payload); + return reader.SafeTryMoveNext() // have root + && reader.Prefix == RespPrefix.Array // root is array + && reader.SafeTryMoveNext() // have first child + && (reader.IsInlneCpuUInt32(pong) || reader.IsInlneCpuUInt32(PONG)); // pong + } + } + + return false; + } + } + + 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); + } + + internal static ReadOnlySpan StackCopyLengthChecked(scoped in RespReader reader, Span buffer) + { + var len = reader.CopyTo(buffer); + if (len == buffer.Length && reader.ScalarLength() > len) return default; // too small + return buffer.Slice(0, len); + } + + private bool OnOutOfBand(ReadOnlySpan payload, ref byte[]? lease) + { + var muxer = BridgeCouldBeNull?.Multiplexer; + if (muxer is null) return true; // consume it blindly + + var reader = new RespReader(payload); + + // read the message kind from the first element + if (reader.SafeTryMoveNext() & reader.IsAggregate & !reader.IsStreaming + && reader.AggregateLength() >= 2 + && (reader.SafeTryMoveNext() & reader.IsInlineScalar & !reader.IsError)) + { + PushKind kind; + unsafe + { + if (!reader.TryParseScalar(&PushKindMetadata.TryParse, out kind)) kind = PushKind.None; + } + + RedisChannel.RedisChannelOptions channelOptions = kind switch + { + PushKind.PMessage or PushKind.PSubscribe or PushKind.PUnsubscribe => RedisChannel.RedisChannelOptions.Pattern, + PushKind.SMessage or PushKind.SSubscribe or PushKind.SUnsubscribe => RedisChannel.RedisChannelOptions.Sharded, + _ => RedisChannel.RedisChannelOptions.None, + }; + + static bool TryMoveNextString(ref RespReader reader) + => reader.SafeTryMoveNext() & reader.IsInlineScalar & + reader.Prefix is RespPrefix.BulkString or RespPrefix.SimpleString; + + if (kind is PushKind.None || !TryMoveNextString(ref reader)) return false; + + // the channel is always the second element + var subscriptionChannel = AsRedisChannel(reader, channelOptions); + + switch (kind) + { + case (PushKind.Message or PushKind.SMessage) when reader.SafeTryMoveNext(): + _readStatus = kind is PushKind.Message ? ReadStatus.PubSubMessage : ReadStatus.PubSubSMessage; + + // special-case the configuration change broadcasts (we don't keep that in the usual pub/sub registry) + var configChanged = muxer.ConfigurationChangedChannel; + if (configChanged != null && reader.Prefix is RespPrefix.BulkString or RespPrefix.SimpleString && subscriptionChannel.Span.SequenceEqual(configChanged)) + { + EndPoint? blame = null; + try + { + if (!reader.Is("*"u8)) + { + // We don't want to fail here, just trying to identify + _ = Format.TryParseEndPoint(reader.ReadString(), out blame); + } + } + catch + { + /* no biggie */ + } + + Trace("Configuration changed: " + Format.ToString(blame)); + _readStatus = ReadStatus.Reconfigure; + muxer.ReconfigureIfNeeded(blame, true, "broadcast"); + } + + // invoke the handlers + if (!subscriptionChannel.IsNull) + { + Trace($"{kind}: {subscriptionChannel}"); + OnMessage(muxer, subscriptionChannel, subscriptionChannel, in reader); + } + + return true; + case PushKind.PMessage when TryMoveNextString(ref reader): + _readStatus = ReadStatus.PubSubPMessage; + + var messageChannel = AsRedisChannel(reader, RedisChannel.RedisChannelOptions.None); + if (!messageChannel.IsNull && reader.SafeTryMoveNext()) + { + Trace($"{kind}: {messageChannel} via {subscriptionChannel}"); + OnMessage(muxer, subscriptionChannel, messageChannel, in reader); + } + + return true; + case PushKind.SUnsubscribe when !PeekChannelMessage(RedisCommand.SUNSUBSCRIBE, subscriptionChannel): + // then it was *unsolicited* - this probably means the slot was migrated + // (otherwise, we'll let the command-processor deal with it) + _readStatus = ReadStatus.PubSubUnsubscribe; + var server = BridgeCouldBeNull?.ServerEndPoint; + if (server is not null && muxer.TryGetSubscription(subscriptionChannel, out var subscription)) + { + // wipe and reconnect; but: to where? + // 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: "sunsubscribe"); + } + return true; + } + } + return false; + } + + private void OnMessage( + ConnectionMultiplexer muxer, + in RedisChannel subscriptionChannel, + in RedisChannel messageChannel, + in RespReader reader) + { + // note: this could be multi-message: https://github.com/StackExchange/StackExchange.Redis/issues/2507 + _readStatus = ReadStatus.InvokePubSub; + switch (reader.Prefix) + { + case RespPrefix.BulkString: + case RespPrefix.SimpleString: + muxer.OnMessage(subscriptionChannel, messageChannel, reader.ReadRedisValue()); + break; + case RespPrefix.Array: + var iter = reader.AggregateChildren(); + while (iter.MoveNext()) + { + muxer.OnMessage(subscriptionChannel, messageChannel, iter.Value.ReadRedisValue()); + } + + break; + } + } + + private void MatchNextResult(ReadOnlySpan frame) + { + Trace("Matching result..."); + + Message? msg = null; + // check whether we're waiting for a high-integrity mode post-response checksum (using cheap null-check first) + if (_awaitingToken is not null && (msg = Interlocked.Exchange(ref _awaitingToken, null)) is not null) + { + _readStatus = ReadStatus.ResponseSequenceCheck; + if (!ProcessHighIntegrityResponseToken(msg, frame, BridgeCouldBeNull)) + { + RecordConnectionFailed(ConnectionFailureType.ResponseIntegrityFailure, origin: nameof(ReadStatus.ResponseSequenceCheck)); + } + return; + } + + _readStatus = ReadStatus.DequeueResult; + lock (_writtenAwaitingResponse) + { + if (msg is not null) + { + _awaitingToken = null; + } + + if (!_writtenAwaitingResponse.TryDequeue(out msg)) + { + Throw(frame, connectionType, _protocol); + + [DoesNotReturn] + static void Throw(ReadOnlySpan frame, ConnectionType connection, RedisProtocol protocol) + { + var prefix = RespReaderExtensions.GetRespPrefix(frame); + throw new InvalidOperationException($"Received {connection}/{protocol} response with no message waiting: " + prefix.ToString()); + } + } + } + _activeMessage = msg; + + Trace("Response to: " + msg); + _readStatus = ReadStatus.ComputeResult; + var reader = new RespReader(frame); + + OnDetailLog($"computing result for {msg.CommandAndKey} ({RespReaderExtensions.GetRespPrefix(frame)})"); + if (msg.ComputeResult(this, ref reader)) + { + OnDetailLog($"> complete: {msg.CommandAndKey}"); + _readStatus = msg.ResultBoxIsAsync ? ReadStatus.CompletePendingMessageAsync : ReadStatus.CompletePendingMessageSync; + if (!msg.IsHighIntegrity) + { + // can't complete yet if needs checksum + msg.Complete(); + } + } + else + { + OnDetailLog($"> incomplete: {msg.CommandAndKey}"); + } + if (msg.IsHighIntegrity) + { + // stash this for the next non-OOB response + Volatile.Write(ref _awaitingToken, msg); + } + + _readStatus = ReadStatus.MatchResultComplete; + _activeMessage = null; + + static bool ProcessHighIntegrityResponseToken(Message message, ReadOnlySpan frame, PhysicalBridge? bridge) + { + bool isValid = false; + var reader = new RespReader(frame); + if ((reader.SafeTryMoveNext() & reader.IsScalar) + && reader.ScalarLength() is 4) + { + uint interpreted; + if (reader.TryGetSpan(out var span)) + { + interpreted = BinaryPrimitives.ReadUInt32LittleEndian(span); + } + else + { + Span tmp = stackalloc byte[4]; + reader.CopyTo(tmp); + interpreted = BinaryPrimitives.ReadUInt32LittleEndian(tmp); + } + isValid = interpreted == message.HighIntegrityToken; + } + if (isValid) + { + message.Complete(); + return true; + } + else + { + message.SetExceptionAndComplete(new InvalidOperationException("High-integrity mode detected possible protocol de-sync"), bridge); + return false; + } + } + } + + private bool PeekChannelMessage(RedisCommand command, in RedisChannel channel) + { + Message? msg; + bool haveMsg; + lock (_writtenAwaitingResponse) + { + haveMsg = _writtenAwaitingResponse.TryPeek(out msg); + } + + return haveMsg && msg is Message.CommandChannelBase typed + && typed.Command == command && typed.Channel == channel; + } + + internal RedisChannel AsRedisChannel(in RespReader reader, RedisChannel.RedisChannelOptions options) + { + var channelPrefix = ChannelPrefix; + if (channelPrefix is null) + { + // no channel-prefix enabled, just use as-is + return new RedisChannel(reader.ReadByteArray(), options); + } + + byte[] lease = []; + var span = reader.TryGetSpan(out var tmp) ? tmp : reader.Buffer(ref lease, stackalloc byte[256]); + + if (span.StartsWith(channelPrefix)) + { + // we have a channel-prefix, and it matches; strip it + span = span.Slice(channelPrefix.Length); + } + else if (span.StartsWith("__keyspace@"u8) || span.StartsWith("__keyevent@"u8)) + { + // we shouldn't get unexpected events, so to get here: we've received a notification + // on a channel that doesn't match our prefix; this *should* be limited to + // key notifications (see: IgnoreChannelPrefix), but: we need to be sure + + // leave alone + } + else + { + // no idea what this is + span = default; + } + + RedisChannel channel = span.IsEmpty ? default : new(span.ToArray(), options); + if (lease.Length != 0) ArrayPool.Shared.Return(lease); + return channel; + } + + [AsciiHash("*2\r\n$4\r\npong\r\n$")] + private static partial class ArrayPong_LC_Bulk { } + [AsciiHash("*2\r\n$4\r\nPONG\r\n$")] + private static partial class ArrayPong_UC_Bulk { } + [AsciiHash("*2\r\n+pong\r\n$")] + private static partial class ArrayPong_LC_Simple { } + [AsciiHash("*2\r\n+PONG\r\n$")] + private static partial class ArrayPong_UC_Simple { } + + // ReSharper disable InconsistentNaming + private static readonly uint + pong = RespConstants.UnsafeCpuUInt32("pong"u8), + PONG = RespConstants.UnsafeCpuUInt32("PONG"u8); + + // ReSharper restore InconsistentNaming + [Conditional("DEBUG")] + private static void DebugValidateSingleFrame(ReadOnlySpan payload) + { + var reader = new RespReader(payload); + if (!reader.TryMoveNext(checkError: false)) + { + throw new InvalidOperationException("No root RESP element"); + } + reader.SkipChildren(); + +#pragma warning disable CS0618 // we don't expect *any* additional data, even attributes + if (reader.TryReadNext()) +#pragma warning restore CS0618 + { + throw new InvalidOperationException($"Unexpected trailing {reader.Prefix}"); + } + + if (reader.ProtocolBytesRemaining != 0) + { + var copy = reader; // leave reader alone for inspection + var prefix = copy.SafeTryMoveNext() ? copy.Prefix : RespPrefix.None; + throw new InvalidOperationException( + $"Unexpected additional {reader.ProtocolBytesRemaining} bytes remaining, {prefix}"); + } + } +} diff --git a/src/StackExchange.Redis/PhysicalConnection.Write.cs b/src/StackExchange.Redis/PhysicalConnection.Write.cs new file mode 100644 index 000000000..84d3430e1 --- /dev/null +++ b/src/StackExchange.Redis/PhysicalConnection.Write.cs @@ -0,0 +1,42 @@ +using System; +using System.Buffers; +using System.IO; +using System.Threading.Tasks; + +namespace StackExchange.Redis; + +internal partial class PhysicalConnection +{ + private BufferedStreamWriter? _output; + private long TotalBytesSent => _output?.TotalBytesWritten ?? 0; + public IBufferWriter Output + { + get + { + return _output ?? Throw(); + static IBufferWriter Throw() => throw new InvalidOperationException("Output pipe not initialized"); + } + } + + private void InitOutput(Stream? stream) + { + if (stream is null) return; + _ioStream = stream; + _output = BufferedStreamWriter.Create(WriteMode, connectionType, stream, OutputCancel); +#if DEBUG + if (BridgeCouldBeNull?.Multiplexer.RawConfig.OutputLog is { } log) + { + _output.DebugSetLog(log); + } +#endif + } + + internal bool HasOutputPipe => _output is not null; + + internal Task CompleteOutputAsync(Exception? exception = null) + { + if (_output is not { } output) return Task.CompletedTask; + output.Complete(exception); + return output.WriteComplete; + } +} diff --git a/src/StackExchange.Redis/PhysicalConnection.cs b/src/StackExchange.Redis/PhysicalConnection.cs index 6ba8b4cde..0ca75b4c1 100644 --- a/src/StackExchange.Redis/PhysicalConnection.cs +++ b/src/StackExchange.Redis/PhysicalConnection.cs @@ -1,11 +1,8 @@ using System; -using System.Buffers; -using System.Buffers.Binary; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.IO.Pipelines; using System.Linq; using System.Net; using System.Net.Security; @@ -19,13 +16,34 @@ using Microsoft.Extensions.Logging; using Pipelines.Sockets.Unofficial; using Pipelines.Sockets.Unofficial.Arenas; -using RESPite; + using static StackExchange.Redis.Message; namespace StackExchange.Redis { internal sealed partial class PhysicalConnection : IDisposable { + // infrastructure to simulate connection death, debug only + private partial bool CanCancel(); + [Conditional("DEBUG")] + partial void OnCancel(bool input, bool output); +#if DEBUG + private readonly CancellationTokenSource _inputCancel = new(), _outputCancel = new(); + internal CancellationToken InputCancel => _inputCancel.Token; + internal CancellationToken OutputCancel => _outputCancel.Token; + + partial void OnCancel(bool input, bool output) + { + if (input) _inputCancel.Cancel(); + if (output) _outputCancel.Cancel(); + } + private partial bool CanCancel() => true; +#else + private partial bool CanCancel() => false; + internal CancellationToken InputCancel => CancellationToken.None; + internal CancellationToken OutputCancel => CancellationToken.None; +#endif + internal readonly byte[]? ChannelPrefix; private const int DefaultRedisDatabaseCount = 16; @@ -64,32 +82,44 @@ private static readonly Message internal void GetBytes(out long sent, out long received) { - if (_ioPipe is IMeasuredDuplexPipe sc) - { - sent = sc.TotalBytesSent; - received = sc.TotalBytesReceived; - } - else - { - sent = received = -1; - } + sent = TotalBytesSent; + received = totalBytesReceived; } /// /// Nullable because during simulation of failure, we'll null out. /// ...but in those cases, we'll accept any null ref in a race - it's fine. /// - private IDuplexPipe? _ioPipe; - internal bool HasOutputPipe => _ioPipe?.Output != null; + private Stream? _ioStream; private Socket? _socket; internal Socket? VolatileSocket => Volatile.Read(ref _socket); - public PhysicalConnection(PhysicalBridge bridge) + // used for dummy test connections + public PhysicalConnection( + ConnectionType connectionType = ConnectionType.Interactive, + RedisProtocol protocol = RedisProtocol.Resp2, + Stream? ioStream = null, + BufferedStreamWriter.WriteMode writeMode = BufferedStreamWriter.WriteMode.Default, + [CallerMemberName] string name = "") + { + lastWriteTickCount = lastReadTickCount = Environment.TickCount; + lastBeatTickCount = 0; + this.connectionType = connectionType; + WriteMode = writeMode; + _protocol = protocol; + _bridge = new WeakReference(null); + _physicalName = name; + InitOutput(ioStream); + OnCreateEcho(); + } + + public PhysicalConnection(PhysicalBridge bridge, BufferedStreamWriter.WriteMode writeMode) { lastWriteTickCount = lastReadTickCount = Environment.TickCount; lastBeatTickCount = 0; connectionType = bridge.ConnectionType; + WriteMode = writeMode; _bridge = new WeakReference(bridge); ChannelPrefix = bridge.Multiplexer.ChannelPrefix; if (ChannelPrefix?.Length == 0) ChannelPrefix = null; // null tests are easier than null+empty @@ -121,7 +151,20 @@ internal async Task BeginConnectAsync(ILogger? log) } if (connectTo is not null) { - _socket = SocketManager.CreateSocket(connectTo); + _socket = CreateSocket(connectTo); + + static Socket CreateSocket(EndPoint endpoint) + { + var addressFamily = endpoint.AddressFamily; + var protocolType = addressFamily == AddressFamily.Unix ? ProtocolType.Unspecified : ProtocolType.Tcp; + + var socket = addressFamily == AddressFamily.Unspecified + ? new Socket(SocketType.Stream, protocolType) + : new Socket(addressFamily, SocketType.Stream, protocolType); + SocketConnection.SetRecommendedClientOptions(socket); + // socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.Linger, false); + return socket; + } } if (_socket is not null) @@ -181,12 +224,12 @@ internal async Task BeginConnectAsync(ILogger? log) { ConnectionMultiplexer.TraceWithoutContext("Socket was already aborted"); } - else if (await ConnectedAsync(x, log, bridge.Multiplexer.SocketManager!).ForAwait()) + else if (await ConnectedAsync(x, log).ForAwait()) { log?.LogInformationStartingRead(new(endpoint)); try { - StartReading(); + StartReading(CancellationToken.None); // this already includes InputCancel // Normal return } catch (Exception ex) @@ -275,7 +318,7 @@ private enum ReadMode : byte private RedisProtocol _protocol; // note starts at **zero**, not RESP2 public RedisProtocol? Protocol => _protocol == 0 ? null : _protocol; - internal void SetProtocol(RedisProtocol value) + public void SetProtocol(RedisProtocol value) { _protocol = value; BridgeCouldBeNull?.SetProtocol(value); @@ -284,18 +327,14 @@ internal void SetProtocol(RedisProtocol value) [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Usage", "CA2202:Do not dispose objects multiple times", Justification = "Trust me yo")] internal void Shutdown() { - var ioPipe = Interlocked.Exchange(ref _ioPipe, null); // compare to the critical read + var output = Interlocked.Exchange(ref _output, null); // compare to the critical read var socket = Interlocked.Exchange(ref _socket, null); - if (ioPipe != null) + if (output != null) { Trace("Disconnecting..."); try { BridgeCouldBeNull?.OnDisconnected(ConnectionFailureType.ConnectionDisposed, this, out _, out _); } catch { } - try { ioPipe.Input?.CancelPendingRead(); } catch { } - try { ioPipe.Input?.Complete(); } catch { } - try { ioPipe.Output?.CancelPendingFlush(); } catch { } - try { ioPipe.Output?.Complete(); } catch { } - try { using (ioPipe as IDisposable) { } } catch { } + try { output.Complete(); } catch { } } if (socket != null) @@ -316,66 +355,31 @@ public void Dispose() RecordConnectionFailed(ConnectionFailureType.ConnectionDisposed); } OnCloseEcho(); - _arena.Dispose(); - _reusableFlushSyncTokenSource?.Dispose(); + // ReSharper disable once GCSuppressFinalizeForTypeWithoutDestructor GC.SuppressFinalize(this); } - private async Task AwaitedFlush(ValueTask flush) - { - await flush.ForAwait(); - _writeStatus = WriteStatus.Flushed; - UpdateLastWriteTime(); - } internal void UpdateLastWriteTime() => Interlocked.Exchange(ref lastWriteTickCount, Environment.TickCount); - public Task FlushAsync() - { - var tmp = _ioPipe?.Output; - if (tmp != null) - { - _writeStatus = WriteStatus.Flushing; - var flush = tmp.FlushAsync(); - if (!flush.IsCompletedSuccessfully) - { - return AwaitedFlush(flush); - } - _writeStatus = WriteStatus.Flushed; - UpdateLastWriteTime(); - } - return Task.CompletedTask; - } + + internal bool CanSimulateConnectionFailure => false; internal void SimulateConnectionFailure(SimulatedFailureType failureType) { - var raiseFailed = false; - if (connectionType == ConnectionType.Interactive) - { - if (failureType.HasFlag(SimulatedFailureType.InteractiveInbound)) - { - _ioPipe?.Input.Complete(new Exception("Simulating interactive input failure")); - raiseFailed = true; - } - if (failureType.HasFlag(SimulatedFailureType.InteractiveOutbound)) - { - _ioPipe?.Output.Complete(new Exception("Simulating interactive output failure")); - raiseFailed = true; - } - } - else if (connectionType == ConnectionType.Subscription) + bool killInput = false, killOutput = false; + switch (connectionType) { - if (failureType.HasFlag(SimulatedFailureType.SubscriptionInbound)) - { - _ioPipe?.Input.Complete(new Exception("Simulating subscription input failure")); - raiseFailed = true; - } - if (failureType.HasFlag(SimulatedFailureType.SubscriptionOutbound)) - { - _ioPipe?.Output.Complete(new Exception("Simulating subscription output failure")); - raiseFailed = true; - } + case ConnectionType.Interactive: + killInput = failureType.HasFlag(SimulatedFailureType.InteractiveInbound); + killOutput = failureType.HasFlag(SimulatedFailureType.InteractiveOutbound); + break; + case ConnectionType.Subscription: + killInput = failureType.HasFlag(SimulatedFailureType.SubscriptionInbound); + killOutput = failureType.HasFlag(SimulatedFailureType.SubscriptionOutbound); + break; } - if (raiseFailed) + if (killInput | killOutput) { + OnCancel(killInput, killOutput); RecordConnectionFailed(ConnectionFailureType.SocketFailure); } } @@ -385,15 +389,14 @@ public void RecordConnectionFailed( Exception? innerException = null, [CallerMemberName] string? origin = null, bool isInitialConnect = false, - IDuplexPipe? connectingPipe = null) + Stream? connectingStream = null) { - bool weAskedForThis; Exception? outerException = innerException; IdentifyFailureType(innerException, ref failureType); var bridge = BridgeCouldBeNull; Message? nextMessage; - if (_ioPipe != null || isInitialConnect) // if *we* didn't burn the pipe: flag it + if (_ioStream is not null || isInitialConnect) // if *we* didn't burn the pipe: flag it { if (failureType == ConnectionFailureType.InternalFailure && innerException is not null) { @@ -433,9 +436,10 @@ public void RecordConnectionFailed( var exMessage = new StringBuilder(failureType.ToString()); // If the reason for the shutdown was we asked for the socket to die, don't log it as an error (only informational) - weAskedForThis = Volatile.Read(ref clientSentQuit) != 0; + var weAskedForThis = Volatile.Read(ref clientSentQuit) != 0; - var pipe = connectingPipe ?? _ioPipe; + /* + var pipe = connectingStream ?? _ioStream; if (pipe is SocketConnection sc) { exMessage.Append(" (").Append(sc.ShutdownKind); @@ -454,9 +458,14 @@ public void RecordConnectionFailed( if (sent == 0) { exMessage.Append(recd == 0 ? " (0-read, 0-sent)" : " (0-sent)"); } else if (recd == 0) { exMessage.Append(" (0-read)"); } } + */ + + long sent = TotalBytesSent, recd = totalBytesReceived; + if (sent == 0) { exMessage.Append(recd == 0 ? " (0-read, 0-sent)" : " (0-sent)"); } + else if (recd == 0) { exMessage.Append(" (0-read)"); } var data = new List>(); - void AddData(string lk, string sk, string? v) + void AddData(string? lk, string? sk, string? v) { if (lk != null) data.Add(Tuple.Create(lk, v)); if (sk != null) exMessage.Append(", ").Append(sk).Append(": ").Append(v); @@ -481,7 +490,6 @@ void AddData(string lk, string sk, string? v) if (unansweredWriteTime != 0) AddData("Unanswered-Write", "unanswered-write", (unchecked(now - unansweredWriteTime) / 1000) + "s ago"); AddData("Keep-Alive", "keep-alive", bridge.ServerEndPoint?.WriteEverySeconds + "s"); AddData("Previous-Physical-State", "state", oldState.ToString()); - AddData("Manager", "mgr", bridge.Multiplexer.SocketManager?.GetState()); if (connStatus.BytesAvailableOnSocket >= 0) AddData("Inbound-Bytes", "in", connStatus.BytesAvailableOnSocket.ToString()); if (connStatus.BytesInReadPipe >= 0) AddData("Inbound-Pipe-Bytes", "in-pipe", connStatus.BytesInReadPipe.ToString()); if (connStatus.BytesInWritePipe >= 0) AddData("Outbound-Pipe-Bytes", "out-pipe", connStatus.BytesInWritePipe.ToString()); @@ -600,10 +608,10 @@ internal static void IdentifyFailureType(Exception? exception, ref ConnectionFai } } - internal void EnqueueInsideWriteLock(Message next) + internal void EnqueueInsideWriteLock(Message next, bool enforceMuxer = true) { var multiplexer = BridgeCouldBeNull?.Multiplexer; - if (multiplexer is null) + if (multiplexer is null & enforceMuxer) // note: this should only be false for testing { // multiplexer already collected? then we're almost certainly doomed; // we can still process it to avoid making things worse/more complex, @@ -807,814 +815,193 @@ internal void SetUnknownDatabase() currentDatabase = -1; } - internal void Write(in RedisKey key) - { - var val = key.KeyValue; - if (val is string s) - { - WriteUnifiedPrefixedString(_ioPipe?.Output, key.KeyPrefix, s); - } - else - { - WriteUnifiedPrefixedBlob(_ioPipe?.Output, key.KeyPrefix, (byte[]?)val); - } - } - - internal void Write(in RedisChannel channel) - => WriteUnifiedPrefixedBlob(_ioPipe?.Output, channel.IgnoreChannelPrefix ? null : ChannelPrefix, channel.Value); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal void WriteBulkString(in RedisValue value) - => WriteBulkString(value, _ioPipe?.Output); - internal static void WriteBulkString(in RedisValue value, IBufferWriter? maybeNullWriter) + internal void RecordQuit() { - if (maybeNullWriter is not { } writer) - { - return; // Prevent null refs during disposal - } - - switch (value.Type) - { - case RedisValue.StorageType.Null: - WriteUnifiedBlob(writer, (byte[]?)null); - break; - case RedisValue.StorageType.Int64: - WriteUnifiedInt64(writer, value.OverlappedValueInt64); - break; - case RedisValue.StorageType.UInt64: - WriteUnifiedUInt64(writer, value.OverlappedValueUInt64); - break; - case RedisValue.StorageType.Double: - WriteUnifiedDouble(writer, value.OverlappedValueDouble); - break; - case RedisValue.StorageType.String: - WriteUnifiedPrefixedString(writer, null, (string?)value); - break; - case RedisValue.StorageType.Raw: - WriteUnifiedSpan(writer, ((ReadOnlyMemory)value).Span); - break; - default: - throw new InvalidOperationException($"Unexpected {value.Type} value: '{value}'"); - } + // don't blame redis if we fired the first shot + Volatile.Write(ref clientSentQuit, 1); + // (_ioPipe as SocketConnection)?.TrySetProtocolShutdown(PipeShutdownKind.ProtocolExitClient); } - internal void WriteBulkString(ReadOnlySpan value) + internal void Flush() { - if (_ioPipe?.Output is { } writer) - { - WriteUnifiedSpan(writer, value); - } + var tmp = _output; + if (tmp is null) Throw(); + _writeStatus = WriteStatus.Flushing; + tmp.Flush(); + _writeStatus = WriteStatus.Flushed; + UpdateLastWriteTime(); + [DoesNotReturn] + static void Throw() => throw new InvalidOperationException("Output pipe not initialized"); } - internal const int REDIS_MAX_ARGS = 1024 * 1024; // there is a <= 1024*1024 max constraint inside redis itself: https://github.com/antirez/redis/blob/6c60526db91e23fb2d666fc52facc9a11780a2a3/src/networking.c#L1024 - - internal void WriteHeader(RedisCommand command, int arguments, CommandBytes commandBytes = default) + internal readonly struct ConnectionStatus { - if (_ioPipe?.Output is not PipeWriter writer) - { - return; // Prevent null refs during disposal - } - - var bridge = BridgeCouldBeNull ?? throw new ObjectDisposedException(ToString()); - - if (command == RedisCommand.UNKNOWN) - { - // using >= here because we will be adding 1 for the command itself (which is an arg for the purposes of the multi-bulk protocol) - if (arguments >= REDIS_MAX_ARGS) throw ExceptionFactory.TooManyArgs(commandBytes.ToString(), arguments); - } - else - { - // using >= here because we will be adding 1 for the command itself (which is an arg for the purposes of the multi-bulk protocol) - if (arguments >= REDIS_MAX_ARGS) throw ExceptionFactory.TooManyArgs(command.ToString(), arguments); - - // for everything that isn't custom commands: ask the muxer for the actual bytes - commandBytes = bridge.Multiplexer.CommandMap.GetBytes(command); - } + /// + /// Number of messages sent outbound, but we don't yet have a response for. + /// + public int MessagesSentAwaitingResponse { get; init; } - // in theory we should never see this; CheckMessage dealt with "regular" messages, and - // ExecuteMessage should have dealt with everything else - if (commandBytes.IsEmpty) throw ExceptionFactory.CommandDisabled(command); + /// + /// Bytes available on the socket, not yet read into the pipe. + /// + public long BytesAvailableOnSocket { get; init; } - // *{argCount}\r\n = 3 + MaxInt32TextLen - // ${cmd-len}\r\n = 3 + MaxInt32TextLen - // {cmd}\r\n = 2 + commandBytes.Length - var span = writer.GetSpan(commandBytes.Length + 8 + Format.MaxInt32TextLen + Format.MaxInt32TextLen); - span[0] = (byte)'*'; + /// + /// Bytes read from the socket, pending in the reader pipe. + /// + public long BytesInReadPipe { get; init; } - int offset = WriteRaw(span, arguments + 1, offset: 1); + /// + /// Bytes in the writer pipe, waiting to be written to the socket. + /// + public long BytesInWritePipe { get; init; } - offset = AppendToSpanCommand(span, commandBytes, offset: offset); + /// + /// Byte size of the last result we processed. + /// + public long BytesLastResult { get; init; } - writer.Advance(offset); - } + /// + /// Byte size on the buffer that isn't processed yet. + /// + public long BytesInBuffer { get; init; } - internal void WriteRaw(ReadOnlySpan bytes) => _ioPipe?.Output?.Write(bytes); + /// + /// The inbound pipe reader status. + /// + public ReadStatus ReadStatus { get; init; } - internal void RecordQuit() - { - // don't blame redis if we fired the first shot - Volatile.Write(ref clientSentQuit, 1); - (_ioPipe as SocketConnection)?.TrySetProtocolShutdown(PipeShutdownKind.ProtocolExitClient); - } + /// + /// The outbound pipe writer status. + /// + public WriteStatus WriteStatus { get; init; } - internal static void WriteMultiBulkHeader(IBufferWriter output, long count) - { - // *{count}\r\n = 3 + MaxInt32TextLen - var span = output.GetSpan(3 + Format.MaxInt32TextLen); - span[0] = (byte)'*'; - int offset = WriteRaw(span, count, offset: 1); - output.Advance(offset); - } + public override string ToString() => + $"SentAwaitingResponse: {MessagesSentAwaitingResponse}, AvailableOnSocket: {BytesAvailableOnSocket} byte(s), InReadPipe: {BytesInReadPipe} byte(s), InWritePipe: {BytesInWritePipe} byte(s), ReadStatus: {ReadStatus}, WriteStatus: {WriteStatus}"; - internal static void WriteMultiBulkHeader(IBufferWriter output, long count, ResultType type) - { - // *{count}\r\n = 3 + MaxInt32TextLen - var span = output.GetSpan(3 + Format.MaxInt32TextLen); - span[0] = type switch + /// + /// The default connection stats, notable *not* the same as default since initializers don't run. + /// + public static ConnectionStatus Default { get; } = new() { - ResultType.Push => (byte)'>', - ResultType.Attribute => (byte)'|', - ResultType.Map => (byte)'%', - ResultType.Set => (byte)'~', - _ => (byte)'*', + BytesAvailableOnSocket = -1, + BytesInReadPipe = -1, + BytesInWritePipe = -1, + ReadStatus = ReadStatus.NA, + WriteStatus = WriteStatus.NA, }; - if ((type is ResultType.Map or ResultType.Attribute) & count > 0) - { - if ((count & 1) != 0) Throw(type, count); - count >>= 1; - static void Throw(ResultType type, long count) => throw new ArgumentOutOfRangeException( - paramName: nameof(count), - message: $"{type} data must be in pairs; got {count}"); - } - int offset = WriteRaw(span, count, offset: 1); - output.Advance(offset); - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static int WriteCrlf(Span span, int offset) - { - span[offset++] = (byte)'\r'; - span[offset++] = (byte)'\n'; - return offset; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static void WriteCrlf(IBufferWriter writer) - { - var span = writer.GetSpan(2); - span[0] = (byte)'\r'; - span[1] = (byte)'\n'; - writer.Advance(2); + /// + /// The zeroed connection stats, which we want to display as zero for default exception cases. + /// + public static ConnectionStatus Zero { get; } = new() + { + BytesAvailableOnSocket = 0, + BytesInReadPipe = 0, + BytesInWritePipe = 0, + ReadStatus = ReadStatus.NA, + WriteStatus = WriteStatus.NA, + }; } - internal static int WriteRaw(Span span, long value, bool withLengthPrefix = false, int offset = 0) + public ConnectionStatus GetStatus() { - if (value >= 0 && value <= 9) - { - if (withLengthPrefix) - { - span[offset++] = (byte)'1'; - offset = WriteCrlf(span, offset); - } - span[offset++] = (byte)((int)'0' + (int)value); - } - else if (value >= 10 && value < 100) - { - if (withLengthPrefix) - { - span[offset++] = (byte)'2'; - offset = WriteCrlf(span, offset); - } - span[offset++] = (byte)((int)'0' + ((int)value / 10)); - span[offset++] = (byte)((int)'0' + ((int)value % 10)); - } - else if (value >= 100 && value < 1000) - { - int v = (int)value; - int units = v % 10; - v /= 10; - int tens = v % 10, hundreds = v / 10; - if (withLengthPrefix) - { - span[offset++] = (byte)'3'; - offset = WriteCrlf(span, offset); - } - span[offset++] = (byte)((int)'0' + hundreds); - span[offset++] = (byte)((int)'0' + tens); - span[offset++] = (byte)((int)'0' + units); - } - else if (value < 0 && value >= -9) - { - if (withLengthPrefix) - { - span[offset++] = (byte)'2'; - offset = WriteCrlf(span, offset); - } - span[offset++] = (byte)'-'; - span[offset++] = (byte)((int)'0' - (int)value); - } - else if (value <= -10 && value > -100) + // Fall back to bytes waiting on the socket if we can + int socketBytes; + try { - if (withLengthPrefix) - { - span[offset++] = (byte)'3'; - offset = WriteCrlf(span, offset); - } - value = -value; - span[offset++] = (byte)'-'; - span[offset++] = (byte)((int)'0' + ((int)value / 10)); - span[offset++] = (byte)((int)'0' + ((int)value % 10)); + socketBytes = VolatileSocket?.Available ?? -1; } - else + catch { - // we're going to write it, but *to the wrong place* - var availableChunk = span.Slice(offset); - var formattedLength = Format.FormatInt64(value, availableChunk); - if (withLengthPrefix) - { - // now we know how large the prefix is: write the prefix, then write the value - var prefixLength = Format.FormatInt32(formattedLength, availableChunk); - offset += prefixLength; - offset = WriteCrlf(span, offset); - - availableChunk = span.Slice(offset); - var finalLength = Format.FormatInt64(value, availableChunk); - offset += finalLength; - Debug.Assert(finalLength == formattedLength); - } - else - { - offset += formattedLength; - } + // If this fails, we're likely in a race disposal situation and do not want to blow sky high here. + socketBytes = -1; } - return WriteCrlf(span, offset); + return new ConnectionStatus() + { + BytesAvailableOnSocket = socketBytes, + BytesInReadPipe = GetReadCommittedLength(), + BytesInWritePipe = -1, + ReadStatus = _readStatus, + WriteStatus = _writeStatus, + BytesLastResult = bytesLastResult, + BytesInBuffer = bytesInBuffer, + }; } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "DEBUG uses instance data")] - private async ValueTask FlushAsync_Awaited(PhysicalConnection connection, ValueTask flush, bool throwOnFailure) + internal static RemoteCertificateValidationCallback? GetAmbientIssuerCertificateCallback() { try { - await flush.ForAwait(); - connection._writeStatus = WriteStatus.Flushed; - connection.UpdateLastWriteTime(); - return WriteResult.Success; + var issuerPath = Environment.GetEnvironmentVariable("SERedis_IssuerCertPath"); + if (!string.IsNullOrEmpty(issuerPath)) return ConfigurationOptions.TrustIssuerCallback(issuerPath); } - catch (ConnectionResetException ex) when (!throwOnFailure) + catch (Exception ex) { - connection.RecordConnectionFailed(ConnectionFailureType.SocketClosed, ex); - return WriteResult.WriteFailure; + Debug.WriteLine(ex.Message); } + return null; } - - private CancellationTokenSource? _reusableFlushSyncTokenSource; - [Obsolete("this is an anti-pattern; work to reduce reliance on this is in progress")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0062:Make local function 'static'", Justification = "DEBUG uses instance data")] - internal WriteResult FlushSync(bool throwOnFailure, int millisecondsTimeout) + internal static LocalCertificateSelectionCallback? GetAmbientClientCertificateCallback() { - var cts = _reusableFlushSyncTokenSource ??= new CancellationTokenSource(); - var flush = FlushAsync(throwOnFailure, cts.Token); - if (!flush.IsCompletedSuccessfully) + try { - // only schedule cancellation if it doesn't complete synchronously; at this point, it is doomed - _reusableFlushSyncTokenSource = null; - cts.CancelAfter(TimeSpan.FromMilliseconds(millisecondsTimeout)); - try - { - // here lies the evil - flush.AsTask().Wait(); - } - catch (AggregateException ex) when (ex.InnerExceptions.Any(e => e is TaskCanceledException)) + var certificatePath = Environment.GetEnvironmentVariable("SERedis_ClientCertPfxPath"); + if (!string.IsNullOrEmpty(certificatePath) && File.Exists(certificatePath)) { - ThrowTimeout(); + var password = Environment.GetEnvironmentVariable("SERedis_ClientCertPassword"); + var pfxStorageFlags = Environment.GetEnvironmentVariable("SERedis_ClientCertStorageFlags"); + X509KeyStorageFlags storageFlags = X509KeyStorageFlags.DefaultKeySet; + if (!string.IsNullOrEmpty(pfxStorageFlags) && Enum.TryParse(pfxStorageFlags, true, out var typedFlags)) + { + storageFlags = typedFlags; + } + + return ConfigurationOptions.CreatePfxUserCertificateCallback(certificatePath, password, storageFlags); } - finally + +#if NET + certificatePath = Environment.GetEnvironmentVariable("SERedis_ClientCertPemPath"); + if (!string.IsNullOrEmpty(certificatePath) && File.Exists(certificatePath)) { - cts.Dispose(); + var passwordPath = Environment.GetEnvironmentVariable("SERedis_ClientCertPasswordPath"); + return ConfigurationOptions.CreatePemUserCertificateCallback(certificatePath, passwordPath); } +#endif } - return flush.Result; - - void ThrowTimeout() - { - throw new TimeoutException("timeout while synchronously flushing"); - } - } - internal ValueTask FlushAsync(bool throwOnFailure, CancellationToken cancellationToken = default) - { - var tmp = _ioPipe?.Output; - if (tmp == null) return new ValueTask(WriteResult.NoConnectionAvailable); - try - { - _writeStatus = WriteStatus.Flushing; - var flush = tmp.FlushAsync(cancellationToken); - if (!flush.IsCompletedSuccessfully) return FlushAsync_Awaited(this, flush, throwOnFailure); - _writeStatus = WriteStatus.Flushed; - UpdateLastWriteTime(); - return new ValueTask(WriteResult.Success); - } - catch (ConnectionResetException ex) when (!throwOnFailure) + catch (Exception ex) { - RecordConnectionFailed(ConnectionFailureType.SocketClosed, ex); - return new ValueTask(WriteResult.WriteFailure); + Debug.WriteLine(ex.Message); } + return null; } - private static readonly ReadOnlyMemory NullBulkString = Encoding.ASCII.GetBytes("$-1\r\n"), EmptyBulkString = Encoding.ASCII.GetBytes("$0\r\n\r\n"); - - private static void WriteUnifiedBlob(IBufferWriter writer, byte[]? value) + internal async ValueTask ConnectedAsync(Socket? socket, ILogger? log) { - if (value == null) - { - // special case: - writer.Write(NullBulkString.Span); - } - else - { - WriteUnifiedSpan(writer, new ReadOnlySpan(value)); - } - } + var bridge = BridgeCouldBeNull; + if (bridge == null) return false; - private static void WriteUnifiedSpan(IBufferWriter writer, ReadOnlySpan value) - { - // ${len}\r\n = 3 + MaxInt32TextLen - // {value}\r\n = 2 + value.Length - const int MaxQuickSpanSize = 512; - if (value.Length == 0) - { - // special case: - writer.Write(EmptyBulkString.Span); - } - else if (value.Length <= MaxQuickSpanSize) - { - var span = writer.GetSpan(5 + Format.MaxInt32TextLen + value.Length); - span[0] = (byte)'$'; - int bytes = AppendToSpan(span, value, 1); - writer.Advance(bytes); - } - else + Stream? stream = null; + try { - // too big to guarantee can do in a single span - var span = writer.GetSpan(3 + Format.MaxInt32TextLen); - span[0] = (byte)'$'; - int bytes = WriteRaw(span, value.Length, offset: 1); - writer.Advance(bytes); + // disallow connection in some cases + OnDebugAbort(); - writer.Write(value); - - WriteCrlf(writer); - } - } - - private static int AppendToSpanCommand(Span span, in CommandBytes value, int offset = 0) - { - span[offset++] = (byte)'$'; - int len = value.Length; - offset = WriteRaw(span, len, offset: offset); - value.CopyTo(span.Slice(offset, len)); - offset += value.Length; - return WriteCrlf(span, offset); - } - - private static int AppendToSpan(Span span, ReadOnlySpan value, int offset = 0) - { - offset = WriteRaw(span, value.Length, offset: offset); - value.CopyTo(span.Slice(offset, value.Length)); - offset += value.Length; - return WriteCrlf(span, offset); - } - - internal void WriteSha1AsHex(byte[] value) - { - if (_ioPipe?.Output is not PipeWriter writer) - { - return; // Prevent null refs during disposal - } - - if (value == null) - { - writer.Write(NullBulkString.Span); - } - else if (value.Length == ResultProcessor.ScriptLoadProcessor.Sha1HashLength) - { - // $40\r\n = 5 - // {40 bytes}\r\n = 42 - var span = writer.GetSpan(47); - span[0] = (byte)'$'; - span[1] = (byte)'4'; - span[2] = (byte)'0'; - span[3] = (byte)'\r'; - span[4] = (byte)'\n'; - - int offset = 5; - for (int i = 0; i < value.Length; i++) - { - var b = value[i]; - span[offset++] = ToHexNibble(b >> 4); - span[offset++] = ToHexNibble(b & 15); - } - span[offset++] = (byte)'\r'; - span[offset++] = (byte)'\n'; - - writer.Advance(offset); - } - else - { - throw new InvalidOperationException("Invalid SHA1 length: " + value.Length); - } - } - - internal static byte ToHexNibble(int value) - { - return value < 10 ? (byte)('0' + value) : (byte)('a' - 10 + value); - } - - internal static void WriteUnifiedPrefixedString(IBufferWriter? maybeNullWriter, byte[]? prefix, string? value) - { - if (maybeNullWriter is not { } writer) - { - return; // Prevent null refs during disposal - } - - if (value == null) - { - // special case - writer.Write(NullBulkString.Span); - } - else - { - // ${total-len}\r\n 3 + MaxInt32TextLen - // {prefix}{value}\r\n - int encodedLength = Encoding.UTF8.GetByteCount(value), - prefixLength = prefix?.Length ?? 0, - totalLength = prefixLength + encodedLength; - - if (totalLength == 0) - { - // special-case - writer.Write(EmptyBulkString.Span); - } - else - { - var span = writer.GetSpan(3 + Format.MaxInt32TextLen); - span[0] = (byte)'$'; - int bytes = WriteRaw(span, totalLength, offset: 1); - writer.Advance(bytes); - - if (prefixLength != 0) writer.Write(prefix); - if (encodedLength != 0) WriteRaw(writer, value, encodedLength); - WriteCrlf(writer); - } - } - } - - [ThreadStatic] - private static Encoder? s_PerThreadEncoder; - internal static Encoder GetPerThreadEncoder() - { - var encoder = s_PerThreadEncoder; - if (encoder == null) - { - s_PerThreadEncoder = encoder = Encoding.UTF8.GetEncoder(); - } - else - { - encoder.Reset(); - } - return encoder; - } - - internal static unsafe void WriteRaw(IBufferWriter writer, string value, int expectedLength) - { - const int MaxQuickEncodeSize = 512; - - fixed (char* cPtr = value) - { - int totalBytes; - if (expectedLength <= MaxQuickEncodeSize) - { - // encode directly in one hit - var span = writer.GetSpan(expectedLength); - fixed (byte* bPtr = span) - { - totalBytes = Encoding.UTF8.GetBytes(cPtr, value.Length, bPtr, expectedLength); - } - writer.Advance(expectedLength); - } - else - { - // use an encoder in a loop - var encoder = GetPerThreadEncoder(); - int charsRemaining = value.Length, charOffset = 0; - totalBytes = 0; - - bool final = false; - while (true) - { - var span = writer.GetSpan(5); // get *some* memory - at least enough for 1 character (but hopefully lots more) - - int charsUsed, bytesUsed; - bool completed; - fixed (byte* bPtr = span) - { - encoder.Convert(cPtr + charOffset, charsRemaining, bPtr, span.Length, final, out charsUsed, out bytesUsed, out completed); - } - writer.Advance(bytesUsed); - totalBytes += bytesUsed; - charOffset += charsUsed; - charsRemaining -= charsUsed; - - if (charsRemaining <= 0) - { - if (charsRemaining < 0) throw new InvalidOperationException("String encode went negative"); - if (completed) break; // fine - if (final) throw new InvalidOperationException("String encode failed to complete"); - final = true; // flush the encoder to one more span, then exit - } - } - } - if (totalBytes != expectedLength) throw new InvalidOperationException("String encode length check failure"); - } - } - - private static void WriteUnifiedPrefixedBlob(PipeWriter? maybeNullWriter, byte[]? prefix, byte[]? value) - { - if (maybeNullWriter is not PipeWriter writer) - { - return; // Prevent null refs during disposal - } - - // ${total-len}\r\n - // {prefix}{value}\r\n - if (prefix == null || prefix.Length == 0 || value == null) - { - // if no prefix, just use the non-prefixed version; - // even if prefixed, a null value writes as null, so can use the non-prefixed version - WriteUnifiedBlob(writer, value); - } - else - { - var span = writer.GetSpan(3 + Format.MaxInt32TextLen); // note even with 2 max-len, we're still in same text range - span[0] = (byte)'$'; - int bytes = WriteRaw(span, prefix.LongLength + value.LongLength, offset: 1); - writer.Advance(bytes); - - writer.Write(prefix); - writer.Write(value); - - span = writer.GetSpan(2); - WriteCrlf(span, 0); - writer.Advance(2); - } - } - - 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" - - // ${asc-len}\r\n = 4/5 (asc-len at most 2 digits) - // {asc}\r\n = MaxInt64TextLen + 2 - var span = writer.GetSpan(7 + Format.MaxInt64TextLen); - - span[0] = (byte)'$'; - var bytes = WriteRaw(span, value, withLengthPrefix: true, offset: 1); - writer.Advance(bytes); - } - - 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" - Span valueSpan = stackalloc byte[Format.MaxInt64TextLen]; - - var len = Format.FormatUInt64(value, valueSpan); - // ${asc-len}\r\n = 4/5 (asc-len at most 2 digits) - // {asc}\r\n = {len} + 2 - var span = writer.GetSpan(7 + len); - span[0] = (byte)'$'; - int offset = WriteRaw(span, len, withLengthPrefix: false, offset: 1); - valueSpan.Slice(0, len).CopyTo(span.Slice(offset)); - offset += len; - offset = WriteCrlf(span, offset); - writer.Advance(offset); - } - - private static void WriteUnifiedDouble(IBufferWriter writer, double value) - { -#if NET8_0_OR_GREATER - Span valueSpan = stackalloc byte[Format.MaxDoubleTextLen]; - var len = Format.FormatDouble(value, valueSpan); - - // ${asc-len}\r\n = 4/5 (asc-len at most 2 digits) - // {asc}\r\n = {len} + 2 - var span = writer.GetSpan(7 + len); - span[0] = (byte)'$'; - int offset = WriteRaw(span, len, withLengthPrefix: false, offset: 1); - valueSpan.Slice(0, len).CopyTo(span.Slice(offset)); - offset += len; - offset = WriteCrlf(span, offset); - writer.Advance(offset); -#else - // fallback: drop to string - WriteUnifiedPrefixedString(writer, null, Format.ToString(value)); -#endif - } - - internal static void WriteInteger(IBufferWriter writer, long value) - { - // note: client should never write integer; only server does this - // :{asc}\r\n = MaxInt64TextLen + 3 - var span = writer.GetSpan(3 + Format.MaxInt64TextLen); - - span[0] = (byte)':'; - var bytes = WriteRaw(span, value, withLengthPrefix: false, offset: 1); - writer.Advance(bytes); - } - - internal readonly struct ConnectionStatus - { - /// - /// Number of messages sent outbound, but we don't yet have a response for. - /// - public int MessagesSentAwaitingResponse { get; init; } - - /// - /// Bytes available on the socket, not yet read into the pipe. - /// - public long BytesAvailableOnSocket { get; init; } - - /// - /// Bytes read from the socket, pending in the reader pipe. - /// - public long BytesInReadPipe { get; init; } - - /// - /// Bytes in the writer pipe, waiting to be written to the socket. - /// - public long BytesInWritePipe { get; init; } - - /// - /// Byte size of the last result we processed. - /// - public long BytesLastResult { get; init; } - - /// - /// Byte size on the buffer that isn't processed yet. - /// - public long BytesInBuffer { get; init; } - - /// - /// The inbound pipe reader status. - /// - public ReadStatus ReadStatus { get; init; } - - /// - /// The outbound pipe writer status. - /// - public WriteStatus WriteStatus { get; init; } - - public override string ToString() => - $"SentAwaitingResponse: {MessagesSentAwaitingResponse}, AvailableOnSocket: {BytesAvailableOnSocket} byte(s), InReadPipe: {BytesInReadPipe} byte(s), InWritePipe: {BytesInWritePipe} byte(s), ReadStatus: {ReadStatus}, WriteStatus: {WriteStatus}"; - - /// - /// The default connection stats, notable *not* the same as default since initializers don't run. - /// - public static ConnectionStatus Default { get; } = new() - { - BytesAvailableOnSocket = -1, - BytesInReadPipe = -1, - BytesInWritePipe = -1, - ReadStatus = ReadStatus.NA, - WriteStatus = WriteStatus.NA, - }; - - /// - /// The zeroed connection stats, which we want to display as zero for default exception cases. - /// - public static ConnectionStatus Zero { get; } = new() - { - BytesAvailableOnSocket = 0, - BytesInReadPipe = 0, - BytesInWritePipe = 0, - ReadStatus = ReadStatus.NA, - WriteStatus = WriteStatus.NA, - }; - } - - public ConnectionStatus GetStatus() - { - if (_ioPipe is SocketConnection conn) - { - var counters = conn.GetCounters(); - return new ConnectionStatus() - { - MessagesSentAwaitingResponse = GetSentAwaitingResponseCount(), - BytesAvailableOnSocket = counters.BytesAvailableOnSocket, - BytesInReadPipe = counters.BytesWaitingToBeRead, - BytesInWritePipe = counters.BytesWaitingToBeSent, - ReadStatus = _readStatus, - WriteStatus = _writeStatus, - BytesLastResult = bytesLastResult, - BytesInBuffer = bytesInBuffer, - }; - } - - // Fall back to bytes waiting on the socket if we can - int fallbackBytesAvailable; - try - { - fallbackBytesAvailable = VolatileSocket?.Available ?? -1; - } - catch - { - // If this fails, we're likely in a race disposal situation and do not want to blow sky high here. - fallbackBytesAvailable = -1; - } - - return new ConnectionStatus() - { - BytesAvailableOnSocket = fallbackBytesAvailable, - BytesInReadPipe = -1, - BytesInWritePipe = -1, - ReadStatus = _readStatus, - WriteStatus = _writeStatus, - BytesLastResult = bytesLastResult, - BytesInBuffer = bytesInBuffer, - }; - } - - internal static RemoteCertificateValidationCallback? GetAmbientIssuerCertificateCallback() - { - try - { - var issuerPath = Environment.GetEnvironmentVariable("SERedis_IssuerCertPath"); - if (!string.IsNullOrEmpty(issuerPath)) return ConfigurationOptions.TrustIssuerCallback(issuerPath); - } - catch (Exception ex) - { - Debug.WriteLine(ex.Message); - } - return null; - } - internal static LocalCertificateSelectionCallback? GetAmbientClientCertificateCallback() - { - try - { - var certificatePath = Environment.GetEnvironmentVariable("SERedis_ClientCertPfxPath"); - if (!string.IsNullOrEmpty(certificatePath) && File.Exists(certificatePath)) - { - var password = Environment.GetEnvironmentVariable("SERedis_ClientCertPassword"); - var pfxStorageFlags = Environment.GetEnvironmentVariable("SERedis_ClientCertStorageFlags"); - X509KeyStorageFlags storageFlags = X509KeyStorageFlags.DefaultKeySet; - if (!string.IsNullOrEmpty(pfxStorageFlags) && Enum.TryParse(pfxStorageFlags, true, out var typedFlags)) - { - storageFlags = typedFlags; - } - - return ConfigurationOptions.CreatePfxUserCertificateCallback(certificatePath, password, storageFlags); - } - -#if NET - certificatePath = Environment.GetEnvironmentVariable("SERedis_ClientCertPemPath"); - if (!string.IsNullOrEmpty(certificatePath) && File.Exists(certificatePath)) - { - var passwordPath = Environment.GetEnvironmentVariable("SERedis_ClientCertPasswordPath"); - return ConfigurationOptions.CreatePemUserCertificateCallback(certificatePath, passwordPath); - } -#endif - } - catch (Exception ex) - { - Debug.WriteLine(ex.Message); - } - return null; - } - - internal async ValueTask ConnectedAsync(Socket? socket, ILogger? log, SocketManager manager) - { - var bridge = BridgeCouldBeNull; - if (bridge == null) return false; - - IDuplexPipe? pipe = null; - try - { - // disallow connection in some cases - OnDebugAbort(); - - // the order is important here: - // non-TLS: [Socket]<==[SocketConnection:IDuplexPipe] - // TLS: [Socket]<==[NetworkStream]<==[SslStream]<==[StreamConnection:IDuplexPipe] - var config = bridge.Multiplexer.RawConfig; + // the order is important here: + // non-TLS: [Socket]<==[SocketConnection:IDuplexPipe] + // TLS: [Socket]<==[NetworkStream]<==[SslStream]<==[StreamConnection:IDuplexPipe] + var config = bridge.Multiplexer.RawConfig; var tunnel = config.Tunnel; - Stream? stream = null; if (tunnel is not null) { stream = await tunnel.BeforeAuthenticateAsync(bridge.ServerEndPoint.EndPoint, bridge.ConnectionType, socket, CancellationToken.None).ForAwait(); } + static Stream DemandSocketStream(Socket? socket) + => new NetworkStream(socket ?? throw new InvalidOperationException("No socket or stream available - possibly a tunnel error")); + if (config.Ssl) { log?.LogInformationConfiguringTLS(); @@ -1624,7 +1011,7 @@ internal async ValueTask ConnectedAsync(Socket? socket, ILogger? log, Sock host = Format.ToStringHostOnly(bridge.ServerEndPoint.EndPoint); } - stream ??= new NetworkStream(socket ?? throw new InvalidOperationException("No socket or stream available - possibly a tunnel error")); + stream ??= DemandSocketStream(socket); var ssl = new SslStream( innerStream: stream, leaveInnerStreamOpen: false, @@ -1667,17 +1054,10 @@ internal async ValueTask ConnectedAsync(Socket? socket, ILogger? log, Sock stream = ssl; } - if (stream is not null) - { - pipe = StreamConnection.GetDuplex(stream, manager.SendPipeOptions, manager.ReceivePipeOptions, name: bridge.Name); - } - else - { - pipe = SocketConnection.Create(socket, manager.SendPipeOptions, manager.ReceivePipeOptions, name: bridge.Name); - } - OnWrapForLogging(ref pipe, _physicalName, manager); + stream ??= DemandSocketStream(socket); + OnWrapForLogging(ref stream, _physicalName); - _ioPipe = pipe; + InitOutput(stream); log?.LogInformationConnected(bridge.Name); @@ -1686,318 +1066,12 @@ internal async ValueTask ConnectedAsync(Socket? socket, ILogger? log, Sock } catch (Exception ex) { - RecordConnectionFailed(ConnectionFailureType.InternalFailure, ex, isInitialConnect: true, connectingPipe: pipe); // includes a bridge.OnDisconnected + RecordConnectionFailed(ConnectionFailureType.InternalFailure, ex, isInitialConnect: true, connectingStream: stream); // includes a bridge.OnDisconnected bridge.Multiplexer.Trace("Could not connect: " + ex.Message, ToString()); return false; } } - 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; - if (len < 2) - { - // for supported cases, we demand at least the kind and the subscription channel - channel = default; - return PushKind.None; - } - - if (result[0].TryParse(PushKindMetadata.TryParse, out PushKind kind) && kind is not PushKind.None) - { - RedisChannel.RedisChannelOptions channelOptions = RedisChannel.RedisChannelOptions.None; - switch (kind) - { - case PushKind.Message when len >= 3: - break; - case PushKind.PMessage when len >= 4: - channelOptions = RedisChannel.RedisChannelOptions.Pattern; - break; - case PushKind.SMessage when len >= 3: - channelOptions = RedisChannel.RedisChannelOptions.Sharded; - break; - case PushKind.Subscribe: - break; - case PushKind.PSubscribe: - channelOptions = RedisChannel.RedisChannelOptions.Pattern; - break; - case PushKind.SSubscribe: - channelOptions = RedisChannel.RedisChannelOptions.Sharded; - break; - case PushKind.Unsubscribe: - break; - case PushKind.PUnsubscribe: - channelOptions = RedisChannel.RedisChannelOptions.Pattern; - break; - case PushKind.SUnsubscribe: - channelOptions = RedisChannel.RedisChannelOptions.Sharded; - break; - default: - kind = PushKind.None; - break; - } - - if (kind != PushKind.None) - { - // the channel is always the second element - channel = result[1].AsRedisChannel(ChannelPrefix, channelOptions); - return kind; - } - } - channel = default; - return PushKind.None; - } - - private void MatchResult(in RawResult result) - { - // check to see if it could be an out-of-band pubsub message - if ((connectionType == ConnectionType.Subscription && result.Resp2TypeArray == ResultType.Array) || result.Resp3Type == ResultType.Push) - { - var muxer = BridgeCouldBeNull?.Multiplexer; - if (muxer == null) return; - - // out of band message does not match to a queued message - var items = result.GetItems(); - var kind = GetPushKind(items, out var subscriptionChannel); - switch (kind) - { - case PushKind.Message: - case PushKind.SMessage: - _readStatus = kind is PushKind.Message ? ReadStatus.PubSubMessage : ReadStatus.PubSubSMessage; - - // special-case the configuration change broadcasts (we don't keep that in the usual pub/sub registry) - var configChanged = muxer.ConfigurationChangedChannel; - if (configChanged != null && items[1].IsEqual(configChanged)) - { - EndPoint? blame = null; - try - { - if (!items[2].IsEqual(CommonReplies.wildcard)) - { - // We don't want to fail here, just trying to identify - _ = Format.TryParseEndPoint(items[2].GetString(), out blame); - } - } - catch - { - /* no biggie */ - } - - Trace("Configuration changed: " + Format.ToString(blame)); - _readStatus = ReadStatus.Reconfigure; - muxer.ReconfigureIfNeeded(blame, true, "broadcast"); - } - - // invoke the handlers - if (!subscriptionChannel.IsNull) - { - Trace($"{kind}: {subscriptionChannel}"); - if (TryGetPubSubPayload(items[2], out var payload)) - { - _readStatus = ReadStatus.InvokePubSub; - muxer.OnMessage(subscriptionChannel, subscriptionChannel, payload); - } - // could be multi-message: https://github.com/StackExchange/StackExchange.Redis/issues/2507 - else if (TryGetMultiPubSubPayload(items[2], out var payloads)) - { - _readStatus = ReadStatus.InvokePubSub; - muxer.OnMessage(subscriptionChannel, subscriptionChannel, payloads); - } - } - return; // and stop processing - case PushKind.PMessage: - _readStatus = ReadStatus.PubSubPMessage; - - var messageChannel = items[2].AsRedisChannel(ChannelPrefix, RedisChannel.RedisChannelOptions.None); - if (!messageChannel.IsNull) - { - Trace($"{kind}: {messageChannel} via {subscriptionChannel}"); - if (TryGetPubSubPayload(items[3], out var payload)) - { - _readStatus = ReadStatus.InvokePubSub; - muxer.OnMessage(subscriptionChannel, messageChannel, payload); - } - else if (TryGetMultiPubSubPayload(items[3], out var payloads)) - { - _readStatus = ReadStatus.InvokePubSub; - muxer.OnMessage(subscriptionChannel, messageChannel, payloads); - } - } - return; // and stop processing - case PushKind.SUnsubscribe when !PeekChannelMessage(RedisCommand.SUNSUBSCRIBE, subscriptionChannel): - // then it was *unsolicited* - this probably means the slot was migrated - // (otherwise, we'll let the command-processor deal with it) - _readStatus = ReadStatus.PubSubUnsubscribe; - var server = BridgeCouldBeNull?.ServerEndPoint; - if (server is not null && muxer.TryGetSubscription(subscriptionChannel, out var subscription)) - { - // wipe and reconnect; but: to where? - // 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: "sunsubscribe"); - } - return; // and STOP PROCESSING; unsolicited - } - } - Trace("Matching result..."); - - Message? msg = null; - // check whether we're waiting for a high-integrity mode post-response checksum (using cheap null-check first) - if (_awaitingToken is not null && (msg = Interlocked.Exchange(ref _awaitingToken, null)) is not null) - { - _readStatus = ReadStatus.ResponseSequenceCheck; - if (!ProcessHighIntegrityResponseToken(msg, in result, BridgeCouldBeNull)) - { - RecordConnectionFailed(ConnectionFailureType.ResponseIntegrityFailure, origin: nameof(ReadStatus.ResponseSequenceCheck)); - } - return; - } - - _readStatus = ReadStatus.DequeueResult; - lock (_writtenAwaitingResponse) - { - if (msg is not null) - { - _awaitingToken = null; - } - - if (!_writtenAwaitingResponse.TryDequeue(out msg)) - { - throw new InvalidOperationException("Received response with no message waiting: " + result.ToString()); - } - } - _activeMessage = msg; - - Trace("Response to: " + msg); - _readStatus = ReadStatus.ComputeResult; - if (msg.ComputeResult(this, result)) - { - _readStatus = msg.ResultBoxIsAsync ? ReadStatus.CompletePendingMessageAsync : ReadStatus.CompletePendingMessageSync; - if (!msg.IsHighIntegrity) - { - // can't complete yet if needs checksum - msg.Complete(); - } - } - if (msg.IsHighIntegrity) - { - // stash this for the next non-OOB response - Volatile.Write(ref _awaitingToken, msg); - } - - _readStatus = ReadStatus.MatchResultComplete; - _activeMessage = null; - - static bool ProcessHighIntegrityResponseToken(Message message, in RawResult result, PhysicalBridge? bridge) - { - bool isValid = false; - if (result.Resp2TypeBulkString == ResultType.BulkString) - { - var payload = result.Payload; - if (payload.Length == 4) - { - uint interpreted; - if (payload.IsSingleSegment) - { - interpreted = BinaryPrimitives.ReadUInt32LittleEndian(payload.First.Span); - } - else - { - Span span = stackalloc byte[4]; - payload.CopyTo(span); - interpreted = BinaryPrimitives.ReadUInt32LittleEndian(span); - } - isValid = interpreted == message.HighIntegrityToken; - } - } - if (isValid) - { - message.Complete(); - return true; - } - else - { - message.SetExceptionAndComplete(new InvalidOperationException("High-integrity mode detected possible protocol de-sync"), bridge); - return false; - } - } - - static bool TryGetPubSubPayload(in RawResult value, out RedisValue parsed, bool allowArraySingleton = true) - { - if (value.IsNull) - { - parsed = RedisValue.Null; - return true; - } - switch (value.Resp2TypeBulkString) - { - case ResultType.Integer: - case ResultType.SimpleString: - case ResultType.BulkString: - parsed = value.AsRedisValue(); - return true; - case ResultType.Array when allowArraySingleton && value.ItemsCount == 1: - return TryGetPubSubPayload(in value[0], out parsed, allowArraySingleton: false); - } - parsed = default; - return false; - } - - static bool TryGetMultiPubSubPayload(in RawResult value, out Sequence parsed) - { - if (value.Resp2TypeArray == ResultType.Array && value.ItemsCount != 0) - { - parsed = value.GetItems(); - return true; - } - parsed = default; - return false; - } - } - - private bool PeekChannelMessage(RedisCommand command, in RedisChannel channel) - { - Message? msg; - bool haveMsg; - lock (_writtenAwaitingResponse) - { - haveMsg = _writtenAwaitingResponse.TryPeek(out msg); - } - - return haveMsg && msg is CommandChannelBase typed - && typed.Command == command && typed.Channel == channel; - } - private volatile Message? _activeMessage; internal void GetHeadMessages(out Message? now, out Message? next) @@ -2036,234 +1110,9 @@ private void OnDebugAbort() } } - partial void OnWrapForLogging(ref IDuplexPipe pipe, string name, SocketManager mgr); + partial void OnWrapForLogging(ref Stream stream, string name); internal void UpdateLastReadTime() => Interlocked.Exchange(ref lastReadTickCount, Environment.TickCount); - private async Task ReadFromPipe() - { - bool allowSyncRead = true, isReading = false; - try - { - _readStatus = ReadStatus.Init; - while (true) - { - var input = _ioPipe?.Input; - if (input == null) break; - - // note: TryRead will give us back the same buffer in a tight loop - // - so: only use that if we're making progress - isReading = true; - _readStatus = ReadStatus.ReadSync; - if (!(allowSyncRead && input.TryRead(out var readResult))) - { - _readStatus = ReadStatus.ReadAsync; - readResult = await input.ReadAsync().ForAwait(); - } - isReading = false; - _readStatus = ReadStatus.UpdateWriteTime; - UpdateLastReadTime(); - - _readStatus = ReadStatus.ProcessBuffer; - var buffer = readResult.Buffer; - int handled = 0; - if (!buffer.IsEmpty) - { - handled = ProcessBuffer(ref buffer); // updates buffer.Start - } - - allowSyncRead = handled != 0; - - _readStatus = ReadStatus.MarkProcessed; - Trace($"Processed {handled} messages"); - input.AdvanceTo(buffer.Start, buffer.End); - - if ((handled == 0 && readResult.IsCompleted) || BridgeCouldBeNull?.NeedsReconnect == true) - { - break; // no more data, trailing incomplete messages, or reconnection required - } - } - Trace("EOF"); - RecordConnectionFailed(ConnectionFailureType.SocketClosed); - _readStatus = ReadStatus.RanToCompletion; - } - catch (Exception ex) - { - _readStatus = ReadStatus.Faulted; - // this CEX is just a hardcore "seriously, read the actual value" - there's no - // convenient "Thread.VolatileRead(ref T field) where T : class", and I don't - // want to make the field volatile just for this one place that needs it - if (isReading) - { - var pipe = Volatile.Read(ref _ioPipe); - if (pipe == null) - { - return; - // yeah, that's fine... don't worry about it; we nuked it - } - - // check for confusing read errors - no need to present "Reading is not allowed after reader was completed." - if (pipe is SocketConnection sc && sc.ShutdownKind == PipeShutdownKind.ReadEndOfStream) - { - RecordConnectionFailed(ConnectionFailureType.SocketClosed, new EndOfStreamException()); - return; - } - } - Trace("Faulted"); - RecordConnectionFailed(ConnectionFailureType.InternalFailure, ex); - } - } - - private static readonly ArenaOptions s_arenaOptions = new ArenaOptions(); - private readonly Arena _arena = new Arena(s_arenaOptions); - - private int ProcessBuffer(ref ReadOnlySequence buffer) - { - int messageCount = 0; - bytesInBuffer = buffer.Length; - - while (!buffer.IsEmpty) - { - _readStatus = ReadStatus.TryParseResult; - var reader = new BufferReader(buffer); - var result = TryParseResult(_protocol >= RedisProtocol.Resp3, _arena, in buffer, ref reader, IncludeDetailInExceptions, this); - try - { - if (result.HasValue) - { - buffer = reader.SliceFromCurrent(); - - messageCount++; - Trace(result.ToString()); - _readStatus = ReadStatus.MatchResult; - MatchResult(result); - - // Track the last result size *after* processing for the *next* error message - bytesInBuffer = buffer.Length; - bytesLastResult = result.Payload.Length; - } - else - { - break; // remaining buffer isn't enough; give up - } - } - finally - { - _readStatus = ReadStatus.ResetArena; - _arena.Reset(); - } - } - _readStatus = ReadStatus.ProcessBufferComplete; - return messageCount; - } - - private static RawResult.ResultFlags AsNull(RawResult.ResultFlags flags) => flags & ~RawResult.ResultFlags.NonNull; - - private static RawResult ReadArray(ResultType resultType, RawResult.ResultFlags flags, Arena arena, in ReadOnlySequence buffer, ref BufferReader reader, bool includeDetailInExceptions, ServerEndPoint? server) - { - var itemCount = ReadLineTerminatedString(ResultType.Integer, flags, ref reader); - if (itemCount.HasValue) - { - if (!itemCount.TryGetInt64(out long i64)) - { - throw ExceptionFactory.ConnectionFailure( - includeDetailInExceptions, - ConnectionFailureType.ProtocolFailure, - itemCount.Is('?') ? "Streamed aggregate types not yet implemented" : "Invalid array length", - server); - } - - int itemCountActual = checked((int)i64); - - if (itemCountActual < 0) - { - // for null response by command like EXEC, RESP array: *-1\r\n - return new RawResult(resultType, items: default, AsNull(flags)); - } - else if (itemCountActual == 0) - { - // for zero array response by command like SCAN, Resp array: *0\r\n - return new RawResult(resultType, items: default, flags); - } - - if (resultType == ResultType.Map) itemCountActual <<= 1; // if it says "3", it means 3 pairs, i.e. 6 values - - var oversized = arena.Allocate(itemCountActual); - var result = new RawResult(resultType, oversized, flags); - - if (oversized.IsSingleSegment) - { - var span = oversized.FirstSpan; - for (int i = 0; i < span.Length; i++) - { - if (!(span[i] = TryParseResult(flags, arena, in buffer, ref reader, includeDetailInExceptions, server)).HasValue) - { - return RawResult.Nil; - } - } - } - else - { - foreach (var span in oversized.Spans) - { - for (int i = 0; i < span.Length; i++) - { - if (!(span[i] = TryParseResult(flags, arena, in buffer, ref reader, includeDetailInExceptions, server)).HasValue) - { - return RawResult.Nil; - } - } - } - } - return result; - } - return RawResult.Nil; - } - - private static RawResult ReadBulkString(ResultType type, RawResult.ResultFlags flags, ref BufferReader reader, bool includeDetailInExceptions, ServerEndPoint? server) - { - var prefix = ReadLineTerminatedString(ResultType.Integer, flags, ref reader); - if (prefix.HasValue) - { - if (!prefix.TryGetInt64(out long i64)) - { - throw ExceptionFactory.ConnectionFailure( - includeDetailInExceptions, - ConnectionFailureType.ProtocolFailure, - prefix.Is('?') ? "Streamed strings not yet implemented" : "Invalid bulk string length", - server); - } - int bodySize = checked((int)i64); - if (bodySize < 0) - { - return new RawResult(type, ReadOnlySequence.Empty, AsNull(flags)); - } - - if (reader.TryConsumeAsBuffer(bodySize, out var payload)) - { - switch (reader.TryConsumeCRLF()) - { - case ConsumeResult.NeedMoreData: - break; // see NilResult below - case ConsumeResult.Success: - return new RawResult(type, payload, flags); - default: - throw ExceptionFactory.ConnectionFailure(includeDetailInExceptions, ConnectionFailureType.ProtocolFailure, "Invalid bulk string terminator", server); - } - } - } - return RawResult.Nil; - } - - private static RawResult ReadLineTerminatedString(ResultType type, RawResult.ResultFlags flags, ref BufferReader reader) - { - int crlfOffsetFromCurrent = BufferReader.FindNextCrLf(reader); - if (crlfOffsetFromCurrent < 0) return RawResult.Nil; - - var payload = reader.ConsumeAsBuffer(crlfOffsetFromCurrent); - reader.Consume(2); - - return new RawResult(type, payload, flags); - } internal enum ReadStatus { @@ -2294,122 +1143,6 @@ internal enum ReadStatus PubSubUnsubscribe, NA = -1, } - private volatile ReadStatus _readStatus; - internal ReadStatus GetReadStatus() => _readStatus; - - internal void StartReading() => ReadFromPipe().RedisFireAndForget(); - - internal static RawResult TryParseResult( - bool isResp3, - Arena arena, - in ReadOnlySequence buffer, - ref BufferReader reader, - bool includeDetilInExceptions, - PhysicalConnection? connection, - bool allowInlineProtocol = false) - { - return TryParseResult( - isResp3 ? (RawResult.ResultFlags.Resp3 | RawResult.ResultFlags.NonNull) : RawResult.ResultFlags.NonNull, - arena, - buffer, - ref reader, - includeDetilInExceptions, - connection?.BridgeCouldBeNull?.ServerEndPoint, - allowInlineProtocol); - } - - private static RawResult TryParseResult( - RawResult.ResultFlags flags, - Arena arena, - in ReadOnlySequence buffer, - ref BufferReader reader, - bool includeDetilInExceptions, - ServerEndPoint? server, - bool allowInlineProtocol = false) - { - int prefix; - do // this loop is just to allow us to parse (skip) attributes without doing a stack-dive - { - prefix = reader.PeekByte(); - if (prefix < 0) return RawResult.Nil; // EOF - switch (prefix) - { - // RESP2 - case '+': // simple string - reader.Consume(1); - return ReadLineTerminatedString(ResultType.SimpleString, flags, ref reader); - case '-': // error - reader.Consume(1); - return ReadLineTerminatedString(ResultType.Error, flags, ref reader); - case ':': // integer - reader.Consume(1); - return ReadLineTerminatedString(ResultType.Integer, flags, ref reader); - case '$': // bulk string - reader.Consume(1); - return ReadBulkString(ResultType.BulkString, flags, ref reader, includeDetilInExceptions, server); - case '*': // array - reader.Consume(1); - return ReadArray(ResultType.Array, flags, arena, in buffer, ref reader, includeDetilInExceptions, server); - // RESP3 - case '_': // null - reader.Consume(1); - return ReadLineTerminatedString(ResultType.Null, flags, ref reader); - case ',': // double - reader.Consume(1); - return ReadLineTerminatedString(ResultType.Double, flags, ref reader); - case '#': // boolean - reader.Consume(1); - return ReadLineTerminatedString(ResultType.Boolean, flags, ref reader); - case '!': // blob error - reader.Consume(1); - return ReadBulkString(ResultType.BlobError, flags, ref reader, includeDetilInExceptions, server); - case '=': // verbatim string - reader.Consume(1); - return ReadBulkString(ResultType.VerbatimString, flags, ref reader, includeDetilInExceptions, server); - case '(': // big number - reader.Consume(1); - return ReadLineTerminatedString(ResultType.BigInteger, flags, ref reader); - case '%': // map - reader.Consume(1); - return ReadArray(ResultType.Map, flags, arena, in buffer, ref reader, includeDetilInExceptions, server); - case '~': // set - reader.Consume(1); - return ReadArray(ResultType.Set, flags, arena, in buffer, ref reader, includeDetilInExceptions, server); - case '|': // attribute - reader.Consume(1); - var arr = ReadArray(ResultType.Attribute, flags, arena, in buffer, ref reader, includeDetilInExceptions, server); - if (!arr.HasValue) return RawResult.Nil; // failed to parse attribute data - - // for now, we want to just skip attribute data; so - // drop whatever we parsed on the floor and keep looking - break; // exits the SWITCH, not the DO/WHILE - case '>': // push - reader.Consume(1); - return ReadArray(ResultType.Push, flags, arena, in buffer, ref reader, includeDetilInExceptions, server); - } - } - while (prefix == '|'); - - if (allowInlineProtocol) return ParseInlineProtocol(flags, arena, ReadLineTerminatedString(ResultType.SimpleString, flags, ref reader)); - throw new InvalidOperationException("Unexpected response prefix: " + (char)prefix); - } - - private static RawResult ParseInlineProtocol(RawResult.ResultFlags flags, Arena arena, in RawResult line) - { - if (!line.HasValue) return RawResult.Nil; // incomplete line - - int count = 0; - foreach (var _ in line.GetInlineTokenizer()) count++; - var block = arena.Allocate(count); - - var iter = block.GetEnumerator(); - foreach (var token in line.GetInlineTokenizer()) - { - // this assigns *via a reference*, returned via the iterator; just... sweet - iter.GetNext() = new RawResult(line.Resp3Type, token, flags); // spoof RESP2 from RESP1 - } - return new RawResult(ResultType.Array, block, flags); // spoof RESP2 from RESP1 - } internal bool HasPendingCallerFacingItems() { diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index ab058de62..6c180196d 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,19 @@ #nullable enable +[SER005]StackExchange.Redis.TestHarness +[SER005]StackExchange.Redis.TestHarness.BufferValidator +[SER005]StackExchange.Redis.TestHarness.ChannelPrefix.get -> StackExchange.Redis.RedisChannel +[SER005]StackExchange.Redis.TestHarness.CommandMap.get -> StackExchange.Redis.CommandMap! +[SER005]StackExchange.Redis.TestHarness.KeyPrefix.get -> StackExchange.Redis.RedisKey +[SER005]StackExchange.Redis.TestHarness.Read(System.ReadOnlySpan value) -> StackExchange.Redis.RedisResult! +[SER005]StackExchange.Redis.TestHarness.TestHarness(StackExchange.Redis.CommandMap? commandMap = null, StackExchange.Redis.RedisChannel channelPrefix = default(StackExchange.Redis.RedisChannel), StackExchange.Redis.RedisKey keyPrefix = default(StackExchange.Redis.RedisKey)) -> void +[SER005]StackExchange.Redis.TestHarness.ValidateResp(string! expected, string! command, params System.Collections.Generic.ICollection! args) -> void +[SER005]StackExchange.Redis.TestHarness.ValidateResp(System.ReadOnlySpan expected, string! command, params System.Collections.Generic.ICollection! args) -> void +[SER005]StackExchange.Redis.TestHarness.ValidateRouting(in StackExchange.Redis.RedisKey expected, params System.Collections.Generic.ICollection! args) -> void +[SER005]StackExchange.Redis.TestHarness.Write(string! command, params System.Collections.Generic.ICollection! args) -> byte[]! +[SER005]StackExchange.Redis.TestHarness.Write(System.Buffers.IBufferWriter! target, string! command, params System.Collections.Generic.ICollection! args) -> void +[SER005]static StackExchange.Redis.TestHarness.AssertEqual(string! expected, System.ReadOnlySpan actual, System.Action! handler) -> void +[SER005]static StackExchange.Redis.TestHarness.AssertEqual(System.ReadOnlySpan expected, System.ReadOnlySpan actual, System.Action, System.ReadOnlyMemory>! handler) -> void +[SER005]virtual StackExchange.Redis.TestHarness.BufferValidator.Invoke(scoped System.ReadOnlySpan buffer) -> void +[SER005]virtual StackExchange.Redis.TestHarness.OnValidateFail(in StackExchange.Redis.RedisKey expected, in StackExchange.Redis.RedisKey actual) -> void +[SER005]virtual StackExchange.Redis.TestHarness.OnValidateFail(string! expected, string! actual) -> void +[SER005]virtual StackExchange.Redis.TestHarness.OnValidateFail(System.ReadOnlyMemory expected, System.ReadOnlyMemory actual) -> void diff --git a/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Shipped.txt index fae4f65ce..bec516e5c 100644 --- a/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Shipped.txt +++ b/src/StackExchange.Redis/PublicAPI/net6.0/PublicAPI.Shipped.txt @@ -1,4 +1,5 @@ -StackExchange.Redis.ConfigurationOptions.SslClientAuthenticationOptions.get -> System.Func? +#nullable enable +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 +StackExchange.Redis.ConfigurationOptions.SetUserPemCertificate(string! userCertificatePath, string? userKeyPath = null) -> void diff --git a/src/StackExchange.Redis/RawResult.cs b/src/StackExchange.Redis/RawResult.cs deleted file mode 100644 index 2b2b3989a..000000000 --- a/src/StackExchange.Redis/RawResult.cs +++ /dev/null @@ -1,549 +0,0 @@ -using System; -using System.Buffers; -using System.Runtime.CompilerServices; -using System.Text; -using Pipelines.Sockets.Unofficial.Arenas; - -namespace StackExchange.Redis -{ - internal readonly struct RawResult - { - internal ref RawResult this[int index] => ref GetItems()[index]; - - 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; - - internal static readonly RawResult Nil = default; - // Note: can't use Memory here - struct recursion breaks runtime - private readonly Sequence _items; - private readonly ResultType _resultType; - private readonly ResultFlags _flags; - - [Flags] - internal enum ResultFlags - { - None = 0, - HasValue = 1 << 0, // simply indicates "not the default" (always set in .ctor) - NonNull = 1 << 1, // defines explicit null; isn't "IsNull" because we want default to be null - Resp3 = 1 << 2, // was the connection in RESP3 mode? - } - - public RawResult(ResultType resultType, in ReadOnlySequence payload, ResultFlags flags) - { - switch (resultType) - { - case ResultType.SimpleString: - case ResultType.Error: - case ResultType.Integer: - case ResultType.BulkString: - case ResultType.Double: - case ResultType.Boolean: - case ResultType.BlobError: - case ResultType.VerbatimString: - case ResultType.BigInteger: - break; - case ResultType.Null: - flags &= ~ResultFlags.NonNull; - break; - default: - ThrowInvalidType(resultType); - break; - } - _resultType = resultType; - _flags = flags | ResultFlags.HasValue; - _payload = payload; - _items = default; - } - - public RawResult(ResultType resultType, Sequence items, ResultFlags flags) - { - switch (resultType) - { - case ResultType.Array: - case ResultType.Map: - case ResultType.Set: - case ResultType.Attribute: - case ResultType.Push: - break; - case ResultType.Null: - flags &= ~ResultFlags.NonNull; - break; - default: - ThrowInvalidType(resultType); - break; - } - _resultType = resultType; - _flags = flags | ResultFlags.HasValue; - _payload = default; - _items = items.Untyped(); - } - - private static void ThrowInvalidType(ResultType resultType) - => throw new ArgumentOutOfRangeException(nameof(resultType), $"Invalid result-type: {resultType}"); - - public bool IsError => _resultType.IsError(); - - public ResultType Resp3Type => _resultType; - - // if null, assume string - public ResultType Resp2TypeBulkString => _resultType == ResultType.Null ? ResultType.BulkString : _resultType.ToResp2(); - // if null, assume array - public ResultType Resp2TypeArray => _resultType == ResultType.Null ? ResultType.Array : _resultType.ToResp2(); - - internal bool IsNull => (_flags & ResultFlags.NonNull) == 0; - - public bool HasValue => (_flags & ResultFlags.HasValue) != 0; - - public bool IsResp3 => (_flags & ResultFlags.Resp3) != 0; - - public override string ToString() - { - if (IsNull) return "(null)"; - - return _resultType.ToResp2() switch - { - ResultType.SimpleString or ResultType.Integer or ResultType.Error => $"{Resp3Type}: {GetString()}", - ResultType.BulkString => $"{Resp3Type}: {Payload.Length} bytes", - ResultType.Array => $"{Resp3Type}: {ItemsCount} items", - _ => $"(unknown: {Resp3Type})", - }; - } - - public Tokenizer GetInlineTokenizer() => new Tokenizer(Payload); - - internal ref struct Tokenizer - { - // tokenizes things according to the inline protocol - // specifically; the line: abc "def ghi" jkl - // is 3 tokens: "abc", "def ghi" and "jkl" - public Tokenizer GetEnumerator() => this; - private BufferReader _value; - - public Tokenizer(scoped in ReadOnlySequence value) - { - _value = new BufferReader(value); - Current = default; - } - - public bool MoveNext() - { - Current = default; - // take any white-space - while (_value.PeekByte() == (byte)' ') { _value.Consume(1); } - - byte terminator = (byte)' '; - var first = _value.PeekByte(); - if (first < 0) return false; // EOF - - switch (first) - { - case (byte)'"': - case (byte)'\'': - // start of string - terminator = (byte)first; - _value.Consume(1); - break; - } - - int end = BufferReader.FindNext(_value, terminator); - if (end < 0) - { - Current = _value.ConsumeToEnd(); - } - else - { - Current = _value.ConsumeAsBuffer(end); - _value.Consume(1); // drop the terminator itself; - } - return true; - } - public ReadOnlySequence Current { get; private set; } - } - - internal RedisChannel AsRedisChannel(byte[]? channelPrefix, RedisChannel.RedisChannelOptions options) - { - switch (Resp2TypeBulkString) - { - case ResultType.SimpleString: - case ResultType.BulkString: - if (channelPrefix is null) - { - // no channel-prefix enabled, just use as-is - return new RedisChannel(GetBlob(), options); - } - if (StartsWith(channelPrefix)) - { - // we have a channel-prefix, and it matches; strip it - byte[] copy = Payload.Slice(channelPrefix.Length).ToArray(); - - return new RedisChannel(copy, options); - } - - // we shouldn't get unexpected events, so to get here: we've received a notification - // on a channel that doesn't match our prefix; this *should* be limited to - // key notifications (see: IgnoreChannelPrefix), but: we need to be sure - if (StartsWith("__keyspace@"u8) || StartsWith("__keyevent@"u8)) - { - // use as-is - return new RedisChannel(GetBlob(), options); - } - return default; - default: - throw new InvalidCastException("Cannot convert to RedisChannel: " + Resp3Type); - } - } - - internal RedisKey AsRedisKey() - { - return Resp2TypeBulkString switch - { - ResultType.SimpleString or ResultType.BulkString => (RedisKey)GetBlob(), - _ => throw new InvalidCastException("Cannot convert to RedisKey: " + Resp3Type), - }; - } - - internal RedisValue AsRedisValue() - { - if (IsNull) return RedisValue.Null; - if (Resp3Type == ResultType.Boolean && Payload.Length == 1) - { - switch (Payload.First.Span[0]) - { - case (byte)'t': return (RedisValue)true; - case (byte)'f': return (RedisValue)false; - } - } - switch (Resp2TypeBulkString) - { - case ResultType.Integer: - long i64; - if (TryGetInt64(out i64)) return (RedisValue)i64; - break; - case ResultType.SimpleString: - case ResultType.BulkString: - return (RedisValue)GetBlob(); - } - throw new InvalidCastException("Cannot convert to RedisValue: " + Resp3Type); - } - - internal Lease? AsLease() - { - if (IsNull) return null; - switch (Resp2TypeBulkString) - { - case ResultType.SimpleString: - case ResultType.BulkString: - var payload = Payload; - var lease = Lease.Create(checked((int)payload.Length), false); - payload.CopyTo(lease.Span); - return lease; - } - throw new InvalidCastException("Cannot convert to Lease: " + Resp3Type); - } - - internal bool IsEqual(in CommandBytes expected) - { - if (expected.Length != Payload.Length) return false; - return new CommandBytes(Payload).Equals(expected); - } - - internal bool IsEqual(byte[]? expected) - { - if (expected == null) throw new ArgumentNullException(nameof(expected)); - return IsEqual(new ReadOnlySpan(expected)); - } - - internal bool IsEqual(ReadOnlySpan expected) - { - var rangeToCheck = Payload; - - if (expected.Length != rangeToCheck.Length) return false; - if (rangeToCheck.IsSingleSegment) return rangeToCheck.First.Span.SequenceEqual(expected); - - int offset = 0; - foreach (var segment in rangeToCheck) - { - var from = segment.Span; - var to = expected.Slice(offset, from.Length); - if (!from.SequenceEqual(to)) return false; - - offset += from.Length; - } - return true; - } - - internal bool StartsWith(in CommandBytes expected) - { - var len = expected.Length; - if (len > Payload.Length) return false; - - var rangeToCheck = Payload.Slice(0, len); - return new CommandBytes(rangeToCheck).Equals(expected); - } - internal bool StartsWith(ReadOnlySpan expected) - { - if (expected.Length > Payload.Length) return false; - - var rangeToCheck = Payload.Slice(0, expected.Length); - if (rangeToCheck.IsSingleSegment) return rangeToCheck.First.Span.SequenceEqual(expected); - - int offset = 0; - foreach (var segment in rangeToCheck) - { - var from = segment.Span; - var to = expected.Slice(offset, from.Length); - if (!from.SequenceEqual(to)) return false; - - offset += from.Length; - } - return true; - } - - internal byte[]? GetBlob() - { - if (IsNull) return null; - - if (Payload.IsEmpty) return Array.Empty(); - - return Payload.ToArray(); - } - - internal bool GetBoolean() - { - if (Payload.Length != 1) throw new InvalidCastException(); - if (Resp3Type == ResultType.Boolean) - { - return Payload.First.Span[0] switch - { - (byte)'t' => true, - (byte)'f' => false, - _ => throw new InvalidCastException(), - }; - } - return Payload.First.Span[0] switch - { - (byte)'1' => true, - (byte)'0' => false, - _ => throw new InvalidCastException(), - }; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal Sequence GetItems() => _items.Cast(); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal double?[]? GetItemsAsDoubles() => this.ToArray((in RawResult x) => x.TryGetDouble(out double val) ? val : null); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal RedisKey[]? GetItemsAsKeys() => this.ToArray((in RawResult x) => x.AsRedisKey()); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal RedisValue[]? GetItemsAsValues() => this.ToArray((in RawResult x) => x.AsRedisValue()); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal string?[]? GetItemsAsStrings() => this.ToArray((in RawResult x) => (string?)x.AsRedisValue()); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal string[]? GetItemsAsStringsNotNullable() => this.ToArray((in RawResult x) => (string)x.AsRedisValue()!); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal bool[]? GetItemsAsBooleans() => this.ToArray((in RawResult x) => (bool)x.AsRedisValue()); - - internal GeoPosition? GetItemsAsGeoPosition() - { - var items = GetItems(); - if (IsNull || items.Length == 0) - { - return null; - } - - ref RawResult root = ref items[0]; - if (root.IsNull) - { - return null; - } - return AsGeoPosition(root.GetItems()); - } - - internal SortedSetEntry[]? GetItemsAsSortedSetEntryArray() => this.ToArray((in RawResult item) => AsSortedSetEntry(item.GetItems())); - - private static SortedSetEntry AsSortedSetEntry(in Sequence elements) - { - if (elements.IsSingleSegment) - { - var span = elements.FirstSpan; - return new SortedSetEntry(span[0].AsRedisValue(), span[1].TryGetDouble(out double val) ? val : double.NaN); - } - else - { - return new SortedSetEntry(elements[0].AsRedisValue(), elements[1].TryGetDouble(out double val) ? val : double.NaN); - } - } - - private static GeoPosition AsGeoPosition(in Sequence coords) - { - double longitude, latitude; - if (coords.IsSingleSegment) - { - var span = coords.FirstSpan; - longitude = (double)span[0].AsRedisValue(); - latitude = (double)span[1].AsRedisValue(); - } - else - { - longitude = (double)coords[0].AsRedisValue(); - latitude = (double)coords[1].AsRedisValue(); - } - - return new GeoPosition(longitude, latitude); - } - - internal GeoPosition?[]? GetItemsAsGeoPositionArray() - => this.ToArray((in RawResult item) => item.IsNull ? default : AsGeoPosition(item.GetItems())); - - internal unsafe string? GetString() => GetString(out _); - internal unsafe string? GetString(out ReadOnlySpan verbatimPrefix) - { - verbatimPrefix = default; - if (IsNull) return null; - if (Payload.IsEmpty) return ""; - - string s; - if (Payload.IsSingleSegment) - { - s = Format.GetString(Payload.First.Span); - return Resp3Type == ResultType.VerbatimString ? GetVerbatimString(s, out verbatimPrefix) : s; - } -#if NET - // use system-provided sequence decoder - return Encoding.UTF8.GetString(in _payload); -#else - var decoder = Encoding.UTF8.GetDecoder(); - int charCount = 0; - foreach (var segment in Payload) - { - var span = segment.Span; - if (span.IsEmpty) continue; - - fixed (byte* bPtr = span) - { - charCount += decoder.GetCharCount(bPtr, span.Length, false); - } - } - - decoder.Reset(); - - s = new string((char)0, charCount); - fixed (char* sPtr = s) - { - char* cPtr = sPtr; - foreach (var segment in Payload) - { - var span = segment.Span; - if (span.IsEmpty) continue; - - fixed (byte* bPtr = span) - { - var written = decoder.GetChars(bPtr, span.Length, cPtr, charCount, false); - if (written < 0 || written > charCount) Throw(); // protect against hypothetical cPtr weirdness - cPtr += written; - charCount -= written; - } - } - } - - return Resp3Type == ResultType.VerbatimString ? GetVerbatimString(s, out verbatimPrefix) : s; - - static void Throw() => throw new InvalidOperationException("Invalid result from GetChars"); -#endif - static string? GetVerbatimString(string? value, out ReadOnlySpan type) - { - // 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. - if (value is not null - && value.Length >= 4 && value[3] == ':') - { - type = value.AsSpan().Slice(0, 3); - value = value.Substring(4); - } - else - { - type = default; - } - return value; - } - } - - internal bool TryGetDouble(out double val) - { - if (IsNull || Payload.IsEmpty) - { - val = 0; - return false; - } - if (TryGetInt64(out long i64)) - { - val = i64; - return true; - } - - if (Payload.IsSingleSegment) return Format.TryParseDouble(Payload.First.Span, out val); - if (Payload.Length < 64) - { - Span span = stackalloc byte[(int)Payload.Length]; - Payload.CopyTo(span); - return Format.TryParseDouble(span, out val); - } - return Format.TryParseDouble(GetString(), out val); - } - - internal bool TryGetInt64(out long value) - { - if (IsNull || Payload.IsEmpty || Payload.Length > Format.MaxInt64TextLen) - { - value = 0; - return false; - } - - if (Payload.IsSingleSegment) return Format.TryParseInt64(Payload.First.Span, out value); - - Span span = stackalloc byte[(int)Payload.Length]; // we already checked the length was <= MaxInt64TextLen - Payload.CopyTo(span); - return Format.TryParseInt64(span, out value); - } - - internal bool Is(char value) - { - var span = Payload.First.Span; - return span.Length == 1 && (char)span[0] == value && Payload.IsSingleSegment; - } - } -} diff --git a/src/StackExchange.Redis/RedisBatch.cs b/src/StackExchange.Redis/RedisBatch.cs index 0ef97f365..beda224af 100644 --- a/src/StackExchange.Redis/RedisBatch.cs +++ b/src/StackExchange.Redis/RedisBatch.cs @@ -50,7 +50,11 @@ public void Execute() } lastBridge = bridge; lastList = list; - + var count = list.Count; + if (count != 0) + { + list[count - 1].SetNoFlush(); + } list.Add(message); } diff --git a/src/StackExchange.Redis/RedisChannel.cs b/src/StackExchange.Redis/RedisChannel.cs index c3acf1493..2327d0a0c 100644 --- a/src/StackExchange.Redis/RedisChannel.cs +++ b/src/StackExchange.Redis/RedisChannel.cs @@ -1,5 +1,4 @@ using System; -using System.Buffers; using System.Diagnostics; using System.Runtime.CompilerServices; using System.Text; diff --git a/src/StackExchange.Redis/RedisDatabase.Strings.cs b/src/StackExchange.Redis/RedisDatabase.Strings.cs index 6fcb7dd3b..dfe4c7259 100644 --- a/src/StackExchange.Redis/RedisDatabase.Strings.cs +++ b/src/StackExchange.Redis/RedisDatabase.Strings.cs @@ -1,5 +1,4 @@ -using System; -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; using System.Threading.Tasks; namespace StackExchange.Redis; diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index ac3c14bcc..90a4cd956 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -5,7 +5,7 @@ using System.Net; using System.Runtime.CompilerServices; using System.Threading.Tasks; -using Pipelines.Sockets.Unofficial.Arenas; +using RESPite.Messages; namespace StackExchange.Redis { @@ -1323,18 +1323,18 @@ public KeyMigrateCommandMessage(int db, RedisKey key, EndPoint toServer, int toD this.migrateOptions = migrateOptions; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { bool isCopy = (migrateOptions & MigrateOptions.Copy) != 0; bool isReplace = (migrateOptions & MigrateOptions.Replace) != 0; - physical.WriteHeader(Command, 5 + (isCopy ? 1 : 0) + (isReplace ? 1 : 0)); - physical.WriteBulkString(toHost); - physical.WriteBulkString(toPort); - physical.Write(Key); - physical.WriteBulkString(toDatabase); - physical.WriteBulkString(timeoutMilliseconds); - if (isCopy) physical.WriteBulkString("COPY"u8); - if (isReplace) physical.WriteBulkString("REPLACE"u8); + writer.WriteHeader(Command, 5 + (isCopy ? 1 : 0) + (isReplace ? 1 : 0)); + writer.WriteBulkString(toHost); + writer.WriteBulkString(toPort); + writer.Write(Key); + writer.WriteBulkString(toDatabase); + writer.WriteBulkString(timeoutMilliseconds); + if (isCopy) writer.WriteBulkString("COPY"u8); + if (isReplace) writer.WriteBulkString("REPLACE"u8); } public override int ArgCount @@ -4255,38 +4255,38 @@ public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) return slot; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, argCount); - physical.WriteBulkString("GROUP"u8); - physical.WriteBulkString(groupName); - physical.WriteBulkString(consumerName); + writer.WriteHeader(Command, argCount); + writer.WriteBulkString("GROUP"u8); + writer.WriteBulkString(groupName); + writer.WriteBulkString(consumerName); if (countPerStream.HasValue) { - physical.WriteBulkString("COUNT"u8); - physical.WriteBulkString(countPerStream.Value); + writer.WriteBulkString("COUNT"u8); + writer.WriteBulkString(countPerStream.Value); } if (noAck) { - physical.WriteBulkString("NOACK"u8); + writer.WriteBulkString("NOACK"u8); } if (claimMinIdleTime.HasValue) { - physical.WriteBulkString("CLAIM"u8); - physical.WriteBulkString(claimMinIdleTime.Value.TotalMilliseconds); + writer.WriteBulkString("CLAIM"u8); + writer.WriteBulkString(claimMinIdleTime.Value.TotalMilliseconds); } - physical.WriteBulkString("STREAMS"u8); + writer.WriteBulkString("STREAMS"u8); for (int i = 0; i < streamPositions.Length; i++) { - physical.Write(streamPositions[i].Key); + writer.Write(streamPositions[i].Key); } for (int i = 0; i < streamPositions.Length; i++) { - physical.WriteBulkString(StreamPosition.Resolve(streamPositions[i].Position, RedisCommand.XREADGROUP)); + writer.WriteBulkString(StreamPosition.Resolve(streamPositions[i].Position, RedisCommand.XREADGROUP)); } } @@ -4335,24 +4335,24 @@ public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) return slot; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, argCount); + writer.WriteHeader(Command, argCount); if (countPerStream.HasValue) { - physical.WriteBulkString("COUNT"u8); - physical.WriteBulkString(countPerStream.Value); + writer.WriteBulkString("COUNT"u8); + writer.WriteBulkString(countPerStream.Value); } - physical.WriteBulkString("STREAMS"u8); + writer.WriteBulkString("STREAMS"u8); for (int i = 0; i < streamPositions.Length; i++) { - physical.Write(streamPositions[i].Key); + writer.Write(streamPositions[i].Key); } for (int i = 0; i < streamPositions.Length; i++) { - physical.WriteBulkString(StreamPosition.Resolve(streamPositions[i].Position, RedisCommand.XREADGROUP)); + writer.WriteBulkString(StreamPosition.Resolve(streamPositions[i].Position, RedisCommand.XREADGROUP)); } } @@ -5058,33 +5058,33 @@ public SingleStreamReadGroupCommandMessage(int db, CommandFlags flags, RedisKey argCount = 6 + (count.HasValue ? 2 : 0) + (noAck ? 1 : 0) + (claimMinIdleTime.HasValue ? 2 : 0); } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, argCount); - physical.WriteBulkString("GROUP"u8); - physical.WriteBulkString(groupName); - physical.WriteBulkString(consumerName); + writer.WriteHeader(Command, argCount); + writer.WriteBulkString("GROUP"u8); + writer.WriteBulkString(groupName); + writer.WriteBulkString(consumerName); if (count.HasValue) { - physical.WriteBulkString("COUNT"u8); - physical.WriteBulkString(count.Value); + writer.WriteBulkString("COUNT"u8); + writer.WriteBulkString(count.Value); } if (noAck) { - physical.WriteBulkString("NOACK"u8); + writer.WriteBulkString("NOACK"u8); } if (claimMinIdleTime.HasValue) { - physical.WriteBulkString("CLAIM"u8); - physical.WriteBulkString(claimMinIdleTime.Value.TotalMilliseconds); + writer.WriteBulkString("CLAIM"u8); + writer.WriteBulkString(claimMinIdleTime.Value.TotalMilliseconds); } - physical.WriteBulkString("STREAMS"u8); - physical.Write(Key); - physical.WriteBulkString(afterId); + writer.WriteBulkString("STREAMS"u8); + writer.Write(Key); + writer.WriteBulkString(afterId); } public override int ArgCount => argCount; @@ -5114,19 +5114,19 @@ public SingleStreamReadCommandMessage(int db, CommandFlags flags, RedisKey key, argCount = count.HasValue ? 5 : 3; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, argCount); + writer.WriteHeader(Command, argCount); if (count.HasValue) { - physical.WriteBulkString("COUNT"u8); - physical.WriteBulkString(count.Value); + writer.WriteBulkString("COUNT"u8); + writer.WriteBulkString(count.Value); } - physical.WriteBulkString("STREAMS"u8); - physical.Write(Key); - physical.WriteBulkString(afterId); + writer.WriteBulkString("STREAMS"u8); + writer.Write(Key); + writer.WriteBulkString(afterId); } public override int ArgCount => argCount; @@ -5559,45 +5559,44 @@ public ScriptLoadMessage(CommandFlags flags, string script) Script = script ?? throw new ArgumentNullException(nameof(script)); } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, 2); - physical.WriteBulkString("LOAD"u8); - physical.WriteBulkString((RedisValue)Script); + writer.WriteHeader(Command, 2); + writer.WriteBulkString("LOAD"u8); + writer.WriteBulkString((RedisValue)Script); } public override int ArgCount => 2; } - private sealed class HashScanResultProcessor : ScanResultProcessor + internal sealed class HashScanResultProcessor : ScanResultProcessor { public static readonly ResultProcessor.ScanResult> Default = new HashScanResultProcessor(); - private HashScanResultProcessor() { } - protected override HashEntry[]? Parse(in RawResult result, out int count) - => HashEntryArray.TryParse(result, out HashEntry[]? pairs, true, out count) ? pairs : null; + internal HashScanResultProcessor() { } + protected override HashEntry[]? Parse(ref RespReader reader, RedisProtocol protocol, out int count) + => HashEntryArray.ParseArray(ref reader, protocol, true, out count, null); } - private abstract class ScanResultProcessor : ResultProcessor.ScanResult> + internal abstract class ScanResultProcessor : ResultProcessor.ScanResult> { - protected abstract T[]? Parse(in RawResult result, out int count); + protected abstract T[]? Parse(ref RespReader reader, RedisProtocol protocol, out int count); - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeArray) + // AggregateLengthIs also handles null (equivalent to zero, in terms of length) + if (reader.IsAggregate && reader.AggregateLengthIs(2)) { - case ResultType.Array: - var arr = result.GetItems(); - if (arr.Length == 2) + var iter = reader.AggregateChildren(); + if (iter.MoveNext() && iter.Value.TryReadInt64(out var i64) && iter.MoveNext()) + { + var innerReader = iter.Value; + if (innerReader.IsAggregate) { - ref RawResult inner = ref arr[1]; - if (inner.Resp2TypeArray == ResultType.Array && arr[0].TryGetInt64(out var i64)) - { - T[]? oversized = Parse(inner, out int count); - var sscanResult = new ScanEnumerable.ScanResult(i64, oversized, count, true); - SetResult(message, sscanResult); - return true; - } + T[]? oversized = Parse(ref innerReader, connection.Protocol.GetValueOrDefault(), out int count); + var sscanResult = new ScanEnumerable.ScanResult(i64, oversized, count, true); + SetResult(message, sscanResult); + return true; } - break; + } } return false; } @@ -5606,43 +5605,77 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes internal sealed class ExecuteMessage : Message { private readonly ICollection _args; - public new CommandBytes Command { get; } + private string _unknownCommand; - public ExecuteMessage(CommandMap? map, int db, CommandFlags flags, string command, ICollection? args) : base(db, flags, RedisCommand.UNKNOWN) + private static int RemoveDbIfNotRequired(int suggestedDb, string adhocCommand, out RedisCommand knownCommand) { - if (args != null && args.Count >= PhysicalConnection.REDIS_MAX_ARGS) // using >= here because we will be adding 1 for the command itself (which is an arg for the purposes of the multi-bulk protocol) + // attempt to parse the ad-hoc command to a known command, so we can apply correct aliasing, etc + if (!RedisCommandMetadata.TryParseCI(adhocCommand, out knownCommand)) + { + knownCommand = RedisCommand.UNKNOWN; + } + if ((knownCommand is not RedisCommand.UNKNOWN & suggestedDb >= 0) && !Message.RequiresDatabase(knownCommand)) + { + // strip the DB; historically we didn't enforce this when IDatabase was + // used to issue known commands as strings, so: don't complain now + // (this is only an issue *because* we now recognise the known commands) + suggestedDb = -1; + } + return suggestedDb; + } + + public ExecuteMessage(CommandMap? map, int db, CommandFlags flags, string command, ICollection? args) + : base(RemoveDbIfNotRequired(db, command, out var knownCommand), flags, knownCommand) + { + if (args != null && args.Count >= MessageWriter.REDIS_MAX_ARGS) // using >= here because we will be adding 1 for the command itself (which is an arg for the purposes of the multi-bulk protocol) { throw ExceptionFactory.TooManyArgs(command, args.Count); } - Command = map?.GetBytes(command) ?? default; - if (Command.IsEmpty) throw ExceptionFactory.CommandDisabled(command); + + map ??= CommandMap.Default; + _unknownCommand = ""; + if (Command is RedisCommand.UNKNOWN) + { + _unknownCommand = command; + } + else if (!map.IsAvailable(Command)) + { + throw ExceptionFactory.CommandDisabled(command); + } _args = args ?? Array.Empty(); } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(RedisCommand.UNKNOWN, _args.Count, Command); + if (Command is RedisCommand.UNKNOWN) + { + writer.WriteHeader(_unknownCommand, _args.Count); + } + else + { + writer.WriteHeader(Command, _args.Count); + } foreach (object arg in _args) { if (arg is RedisKey key) { - physical.Write(key); + writer.Write(key); } else if (arg is RedisChannel channel) { - physical.Write(channel); + writer.Write(channel); } else { // recognises well-known types var val = RedisValue.TryParse(arg, out var valid); if (!valid) throw new InvalidCastException($"Unable to parse value: '{arg}'"); - physical.WriteBulkString(val); + writer.WriteBulkString(val); } } } - public override string CommandString => Command.ToString(); - public override string CommandAndKey => Command.ToString(); + public override string CommandString => Command is RedisCommand.UNKNOWN ? _unknownCommand : base.CommandString; + public override string CommandAndKey => CommandString; public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) { @@ -5725,47 +5758,55 @@ public IEnumerable GetMessages(PhysicalConnection connection) yield return this; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { if (hexHash != null) { - physical.WriteHeader(RedisCommand.EVALSHA, 2 + keys.Length + values.Length); - physical.WriteSha1AsHex(hexHash); + writer.WriteHeader(RedisCommand.EVALSHA, 2 + keys.Length + values.Length); + writer.WriteSha1AsHex(hexHash); } else if (asciiHash != null) { - physical.WriteHeader(RedisCommand.EVALSHA, 2 + keys.Length + values.Length); - physical.WriteBulkString((RedisValue)asciiHash); + writer.WriteHeader(RedisCommand.EVALSHA, 2 + keys.Length + values.Length); + writer.WriteBulkString((RedisValue)asciiHash); } else { - physical.WriteHeader(RedisCommand.EVAL, 2 + keys.Length + values.Length); - physical.WriteBulkString((RedisValue)script); + writer.WriteHeader(RedisCommand.EVAL, 2 + keys.Length + values.Length); + writer.WriteBulkString((RedisValue)script); } - physical.WriteBulkString(keys.Length); + writer.WriteBulkString(keys.Length); for (int i = 0; i < keys.Length; i++) - physical.Write(keys[i]); + writer.Write(keys[i]); for (int i = 0; i < values.Length; i++) - physical.WriteBulkString(values[i]); + writer.WriteBulkString(values[i]); } public override int ArgCount => 2 + keys.Length + values.Length; } - private sealed class SetScanResultProcessor : ScanResultProcessor + internal sealed class SetScanResultProcessor : ScanResultProcessor { public static readonly ResultProcessor.ScanResult> Default = new SetScanResultProcessor(); - private SetScanResultProcessor() { } - protected override RedisValue[] Parse(in RawResult result, out int count) + internal SetScanResultProcessor() { } + protected override RedisValue[] Parse(ref RespReader reader, RedisProtocol protocol, out int count) { - var items = result.GetItems(); - if (items.IsEmpty) + if (reader.IsNull) { count = 0; return Array.Empty(); } - count = (int)items.Length; + count = reader.AggregateLength(); + if (count == 0) + { + return Array.Empty(); + } RedisValue[] arr = ArrayPool.Shared.Rent(count); - items.CopyTo(arr, (in RawResult r) => r.AsRedisValue()); + var iter = reader.AggregateChildren(); + for (int i = 0; i < count; i++) + { + iter.DemandNext(); + arr[i] = iter.Value.ReadRedisValue(); + } return arr; } } @@ -5858,25 +5899,25 @@ public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) return slot; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(Command, 2 + keys.Length + values.Length); - physical.Write(Key); - physical.WriteBulkString(keys.Length); + writer.WriteHeader(Command, 2 + keys.Length + values.Length); + writer.Write(Key); + writer.WriteBulkString(keys.Length); for (int i = 0; i < keys.Length; i++) - physical.Write(keys[i]); + writer.Write(keys[i]); for (int i = 0; i < values.Length; i++) - physical.WriteBulkString(values[i]); + writer.WriteBulkString(values[i]); } public override int ArgCount => 2 + keys.Length + values.Length; } - private sealed class SortedSetScanResultProcessor : ScanResultProcessor + internal sealed class SortedSetScanResultProcessor : ScanResultProcessor { public static readonly ResultProcessor.ScanResult> Default = new SortedSetScanResultProcessor(); - private SortedSetScanResultProcessor() { } - protected override SortedSetEntry[]? Parse(in RawResult result, out int count) - => SortedSetWithScores.TryParse(result, out SortedSetEntry[]? pairs, true, out count) ? pairs : null; + internal SortedSetScanResultProcessor() { } + protected override SortedSetEntry[]? Parse(ref RespReader reader, RedisProtocol protocol, out int count) + => SortedSetWithScores.ParseArray(ref reader, protocol, true, out count, null); } private sealed class StringGetWithExpiryMessage : Message.CommandKeyBase, IMultiMessage @@ -5915,10 +5956,10 @@ public bool UnwrapValue(out TimeSpan? value, out Exception? ex) return false; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - physical.WriteHeader(command, 1); - physical.Write(Key); + writer.WriteHeader(command, 1); + writer.Write(Key); } public override int ArgCount => 1; } @@ -5927,27 +5968,23 @@ private sealed class StringGetWithExpiryProcessor : ResultProcessor Default = new StringGetWithExpiryProcessor(); private StringGetWithExpiryProcessor() { } - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + if (reader.IsScalar) { - case ResultType.Integer: - case ResultType.SimpleString: - case ResultType.BulkString: - RedisValue value = result.AsRedisValue(); - if (message is StringGetWithExpiryMessage sgwem && sgwem.UnwrapValue(out var expiry, out var ex)) + RedisValue value = reader.ReadRedisValue(); + if (message is StringGetWithExpiryMessage sgwem && sgwem.UnwrapValue(out var expiry, out var ex)) + { + if (ex == null) { - if (ex == null) - { - SetResult(message, new RedisValueWithExpiry(value, expiry)); - } - else - { - SetException(message, ex); - } - return true; + SetResult(message, new RedisValueWithExpiry(value, expiry)); } - break; + else + { + SetException(message, ex); + } + return true; + } } return false; } diff --git a/src/StackExchange.Redis/RedisLiterals.cs b/src/StackExchange.Redis/RedisLiterals.cs index 9a8f54971..c7773879d 100644 --- a/src/StackExchange.Redis/RedisLiterals.cs +++ b/src/StackExchange.Redis/RedisLiterals.cs @@ -4,212 +4,177 @@ namespace StackExchange.Redis { #pragma warning disable SA1310 // Field names should not contain underscore #pragma warning disable SA1311 // Static readonly fields should begin with upper-case letter - internal static partial class CommonReplies - { - public static readonly CommandBytes - ASK = "ASK ", - authFail_trimmed = CommandBytes.TrimToFit("ERR operation not permitted"), - backgroundSavingStarted_trimmed = CommandBytes.TrimToFit("Background saving started"), - backgroundSavingAOFStarted_trimmed = - CommandBytes.TrimToFit("Background append only file rewriting started"), - databases = "databases", - loading = "LOADING ", - MOVED = "MOVED ", - NOAUTH = "NOAUTH ", - NOSCRIPT = "NOSCRIPT ", - no = "no", - OK = "OK", - one = "1", - PONG = "PONG", - QUEUED = "QUEUED", - READONLY = "READONLY ", - replica_read_only = "replica-read-only", - slave_read_only = "slave-read-only", - timeout = "timeout", - wildcard = "*", - WRONGPASS = "WRONGPASS", - yes = "yes", - zero = "0", - - // HELLO - version = "version", - proto = "proto", - role = "role", - mode = "mode", - id = "id"; - } - internal static class RedisLiterals { // unlike primary commands, these do not get altered by the command-map; we may as // well compute the bytes once and share them public static readonly RedisValue - ACLCAT = "ACLCAT", - ADDR = "ADDR", - AFTER = "AFTER", - AGGREGATE = "AGGREGATE", - ALPHA = "ALPHA", - AND = "AND", - ANDOR = "ANDOR", - ANY = "ANY", - ASC = "ASC", - BEFORE = "BEFORE", - BIT = "BIT", - BY = "BY", - BYLEX = "BYLEX", - BYSCORE = "BYSCORE", - BYTE = "BYTE", - CH = "CH", - CHANNELS = "CHANNELS", - COUNT = "COUNT", - DB = "DB", - @default = "default", - DESC = "DESC", - DIFF = "DIFF", - DIFF1 = "DIFF1", - DOCTOR = "DOCTOR", - ENCODING = "ENCODING", - EX = "EX", - EXAT = "EXAT", - EXISTS = "EXISTS", - FIELDS = "FIELDS", - FILTERBY = "FILTERBY", - FLUSH = "FLUSH", - FNX = "FNX", - FREQ = "FREQ", - FXX = "FXX", - GET = "GET", - GETKEYS = "GETKEYS", - GETNAME = "GETNAME", - GT = "GT", - HISTORY = "HISTORY", - ID = "ID", - IDX = "IDX", - IDLETIME = "IDLETIME", - IDMP = "IDMP", - IDMPAUTO = "IDMPAUTO", - IDMP_DURATION = "IDMP-DURATION", - IDMP_MAXSIZE = "IDMP-MAXSIZE", - KEEPTTL = "KEEPTTL", - KILL = "KILL", - LADDR = "LADDR", - LATEST = "LATEST", - LEFT = "LEFT", - LEN = "LEN", - lib_name = "lib-name", - lib_ver = "lib-ver", - LIMIT = "LIMIT", - LIST = "LIST", - LT = "LT", - MATCH = "MATCH", - MALLOC_STATS = "MALLOC-STATS", - MAX = "MAX", - MAXAGE = "MAXAGE", - MAXLEN = "MAXLEN", - MIN = "MIN", - MINMATCHLEN = "MINMATCHLEN", - MODULE = "MODULE", - NODES = "NODES", - NOSAVE = "NOSAVE", - NOT = "NOT", - NOVALUES = "NOVALUES", - NUMPAT = "NUMPAT", - NUMSUB = "NUMSUB", - NX = "NX", - OBJECT = "OBJECT", - ONE = "ONE", - OR = "OR", - PATTERN = "PATTERN", - PAUSE = "PAUSE", - PERSIST = "PERSIST", - PING = "PING", - PURGE = "PURGE", - PX = "PX", - PXAT = "PXAT", - RANK = "RANK", - REFCOUNT = "REFCOUNT", - REPLACE = "REPLACE", - RESET = "RESET", - RESETSTAT = "RESETSTAT", - REV = "REV", - REWRITE = "REWRITE", - RIGHT = "RIGHT", - SAVE = "SAVE", - SEGFAULT = "SEGFAULT", - SET = "SET", - SETINFO = "SETINFO", - SETNAME = "SETNAME", - SKIPME = "SKIPME", - STATS = "STATS", - STOP = "STOP", - STORE = "STORE", - TYPE = "TYPE", - USERNAME = "USERNAME", - WEIGHTS = "WEIGHTS", - WITHMATCHLEN = "WITHMATCHLEN", - WITHSCORES = "WITHSCORES", - WITHVALUES = "WITHVALUES", - XOR = "XOR", - XX = "XX", + ACLCAT = RedisValue.FromRaw("ACLCAT"u8), + ADDR = RedisValue.FromRaw("ADDR"u8), + AFTER = RedisValue.FromRaw("AFTER"u8), + AGGREGATE = RedisValue.FromRaw("AGGREGATE"u8), + ALPHA = RedisValue.FromRaw("ALPHA"u8), + AND = RedisValue.FromRaw("AND"u8), + ANDOR = RedisValue.FromRaw("ANDOR"u8), + ANY = RedisValue.FromRaw("ANY"u8), + ASC = RedisValue.FromRaw("ASC"u8), + BEFORE = RedisValue.FromRaw("BEFORE"u8), + BIT = RedisValue.FromRaw("BIT"u8), + BY = RedisValue.FromRaw("BY"u8), + BYLEX = RedisValue.FromRaw("BYLEX"u8), + BYSCORE = RedisValue.FromRaw("BYSCORE"u8), + BYTE = RedisValue.FromRaw("BYTE"u8), + CH = RedisValue.FromRaw("CH"u8), + CHANNELS = RedisValue.FromRaw("CHANNELS"u8), + COUNT = RedisValue.FromRaw("COUNT"u8), + DB = RedisValue.FromRaw("DB"u8), + @default = RedisValue.FromRaw("default"u8), + DESC = RedisValue.FromRaw("DESC"u8), + DIFF = RedisValue.FromRaw("DIFF"u8), + DIFF1 = RedisValue.FromRaw("DIFF1"u8), + DOCTOR = RedisValue.FromRaw("DOCTOR"u8), + ENCODING = RedisValue.FromRaw("ENCODING"u8), + EX = RedisValue.FromRaw("EX"u8), + EXAT = RedisValue.FromRaw("EXAT"u8), + EXISTS = RedisValue.FromRaw("EXISTS"u8), + FIELDS = RedisValue.FromRaw("FIELDS"u8), + FILTERBY = RedisValue.FromRaw("FILTERBY"u8), + FLUSH = RedisValue.FromRaw("FLUSH"u8), + FNX = RedisValue.FromRaw("FNX"u8), + FREQ = RedisValue.FromRaw("FREQ"u8), + FXX = RedisValue.FromRaw("FXX"u8), + GET = RedisValue.FromRaw("GET"u8), + GETKEYS = RedisValue.FromRaw("GETKEYS"u8), + GETNAME = RedisValue.FromRaw("GETNAME"u8), + GT = RedisValue.FromRaw("GT"u8), + HISTORY = RedisValue.FromRaw("HISTORY"u8), + ID = RedisValue.FromRaw("ID"u8), + IDX = RedisValue.FromRaw("IDX"u8), + IDLETIME = RedisValue.FromRaw("IDLETIME"u8), + IDMP = RedisValue.FromRaw("IDMP"u8), + IDMPAUTO = RedisValue.FromRaw("IDMPAUTO"u8), + IDMP_DURATION = RedisValue.FromRaw("IDMP-DURATION"u8), + IDMP_MAXSIZE = RedisValue.FromRaw("IDMP-MAXSIZE"u8), + KEEPTTL = RedisValue.FromRaw("KEEPTTL"u8), + KILL = RedisValue.FromRaw("KILL"u8), + LADDR = RedisValue.FromRaw("LADDR"u8), + LATEST = RedisValue.FromRaw("LATEST"u8), + LEFT = RedisValue.FromRaw("LEFT"u8), + LEN = RedisValue.FromRaw("LEN"u8), + lib_name = RedisValue.FromRaw("lib-name"u8), + lib_ver = RedisValue.FromRaw("lib-ver"u8), + LIMIT = RedisValue.FromRaw("LIMIT"u8), + LIST = RedisValue.FromRaw("LIST"u8), + LT = RedisValue.FromRaw("LT"u8), + MATCH = RedisValue.FromRaw("MATCH"u8), + MALLOC_STATS = RedisValue.FromRaw("MALLOC-STATS"u8), + MAX = RedisValue.FromRaw("MAX"u8), + MAXAGE = RedisValue.FromRaw("MAXAGE"u8), + MAXLEN = RedisValue.FromRaw("MAXLEN"u8), + MIN = RedisValue.FromRaw("MIN"u8), + MINMATCHLEN = RedisValue.FromRaw("MINMATCHLEN"u8), + MODULE = RedisValue.FromRaw("MODULE"u8), + NODES = RedisValue.FromRaw("NODES"u8), + NOSAVE = RedisValue.FromRaw("NOSAVE"u8), + NOT = RedisValue.FromRaw("NOT"u8), + NOVALUES = RedisValue.FromRaw("NOVALUES"u8), + NUMPAT = RedisValue.FromRaw("NUMPAT"u8), + NUMSUB = RedisValue.FromRaw("NUMSUB"u8), + NX = RedisValue.FromRaw("NX"u8), + OBJECT = RedisValue.FromRaw("OBJECT"u8), + ONE = RedisValue.FromRaw("ONE"u8), + OR = RedisValue.FromRaw("OR"u8), + PATTERN = RedisValue.FromRaw("PATTERN"u8), + PAUSE = RedisValue.FromRaw("PAUSE"u8), + PERSIST = RedisValue.FromRaw("PERSIST"u8), + PING = RedisValue.FromRaw("PING"u8), + PURGE = RedisValue.FromRaw("PURGE"u8), + PX = RedisValue.FromRaw("PX"u8), + PXAT = RedisValue.FromRaw("PXAT"u8), + RANK = RedisValue.FromRaw("RANK"u8), + REFCOUNT = RedisValue.FromRaw("REFCOUNT"u8), + REPLACE = RedisValue.FromRaw("REPLACE"u8), + RESET = RedisValue.FromRaw("RESET"u8), + RESETSTAT = RedisValue.FromRaw("RESETSTAT"u8), + REV = RedisValue.FromRaw("REV"u8), + REWRITE = RedisValue.FromRaw("REWRITE"u8), + RIGHT = RedisValue.FromRaw("RIGHT"u8), + SAVE = RedisValue.FromRaw("SAVE"u8), + SEGFAULT = RedisValue.FromRaw("SEGFAULT"u8), + SET = RedisValue.FromRaw("SET"u8), + SETINFO = RedisValue.FromRaw("SETINFO"u8), + SETNAME = RedisValue.FromRaw("SETNAME"u8), + SKIPME = RedisValue.FromRaw("SKIPME"u8), + STATS = RedisValue.FromRaw("STATS"u8), + STOP = RedisValue.FromRaw("STOP"u8), + STORE = RedisValue.FromRaw("STORE"u8), + TYPE = RedisValue.FromRaw("TYPE"u8), + USERNAME = RedisValue.FromRaw("USERNAME"u8), + WEIGHTS = RedisValue.FromRaw("WEIGHTS"u8), + WITHMATCHLEN = RedisValue.FromRaw("WITHMATCHLEN"u8), + WITHSCORES = RedisValue.FromRaw("WITHSCORES"u8), + WITHVALUES = RedisValue.FromRaw("WITHVALUES"u8), + XOR = RedisValue.FromRaw("XOR"u8), + XX = RedisValue.FromRaw("XX"u8), // Sentinel Literals - MASTERS = "MASTERS", - MASTER = "MASTER", - REPLICAS = "REPLICAS", - SLAVES = "SLAVES", - GETMASTERADDRBYNAME = "GET-MASTER-ADDR-BY-NAME", - // RESET = "RESET", - FAILOVER = "FAILOVER", - SENTINELS = "SENTINELS", + MASTERS = RedisValue.FromRaw("MASTERS"u8), + MASTER = RedisValue.FromRaw("MASTER"u8), + REPLICAS = RedisValue.FromRaw("REPLICAS"u8), + SLAVES = RedisValue.FromRaw("SLAVES"u8), + GETMASTERADDRBYNAME = RedisValue.FromRaw("GET-MASTER-ADDR-BY-NAME"u8), + // RESET = RedisValue.FromRaw("RESET"u8), + FAILOVER = RedisValue.FromRaw("FAILOVER"u8), + SENTINELS = RedisValue.FromRaw("SENTINELS"u8), // Sentinel Literals as of 2.8.4 - MONITOR = "MONITOR", - REMOVE = "REMOVE", - // SET = "SET", + MONITOR = RedisValue.FromRaw("MONITOR"u8), + REMOVE = RedisValue.FromRaw("REMOVE"u8), + // SET = RedisValue.FromRaw("SET"u8), // replication states - connect = "connect", - connected = "connected", - connecting = "connecting", - handshake = "handshake", - none = "none", - sync = "sync", + connect = RedisValue.FromRaw("connect"u8), + connected = RedisValue.FromRaw("connected"u8), + connecting = RedisValue.FromRaw("connecting"u8), + handshake = RedisValue.FromRaw("handshake"u8), + none = RedisValue.FromRaw("none"u8), + sync = RedisValue.FromRaw("sync"u8), - MinusSymbol = "-", - PlusSymbol = "+", - Wildcard = "*", + MinusSymbol = RedisValue.FromRaw("-"u8), + PlusSymbol = RedisValue.FromRaw("+"u8), + Wildcard = RedisValue.FromRaw("*"u8), // Geo Radius/Search Literals - BYBOX = "BYBOX", - BYRADIUS = "BYRADIUS", - FROMMEMBER = "FROMMEMBER", - FROMLONLAT = "FROMLONLAT", - STOREDIST = "STOREDIST", - WITHCOORD = "WITHCOORD", - WITHDIST = "WITHDIST", - WITHHASH = "WITHHASH", + BYBOX = RedisValue.FromRaw("BYBOX"u8), + BYRADIUS = RedisValue.FromRaw("BYRADIUS"u8), + FROMMEMBER = RedisValue.FromRaw("FROMMEMBER"u8), + FROMLONLAT = RedisValue.FromRaw("FROMLONLAT"u8), + STOREDIST = RedisValue.FromRaw("STOREDIST"u8), + WITHCOORD = RedisValue.FromRaw("WITHCOORD"u8), + WITHDIST = RedisValue.FromRaw("WITHDIST"u8), + WITHHASH = RedisValue.FromRaw("WITHHASH"u8), // geo units - ft = "ft", - km = "km", - m = "m", - mi = "mi", + ft = RedisValue.FromRaw("ft"u8), + km = RedisValue.FromRaw("km"u8), + m = RedisValue.FromRaw("m"u8), + mi = RedisValue.FromRaw("mi"u8), // misc (config, etc) - databases = "databases", - master = "master", - no = "no", - normal = "normal", - pubsub = "pubsub", - replica = "replica", - replica_read_only = "replica-read-only", - replication = "replication", - sentinel = "sentinel", - server = "server", - slave = "slave", - slave_read_only = "slave-read-only", - timeout = "timeout", - yes = "yes"; + databases = RedisValue.FromRaw("databases"u8), + master = RedisValue.FromRaw("master"u8), + no = RedisValue.FromRaw("no"u8), + normal = RedisValue.FromRaw("normal"u8), + pubsub = RedisValue.FromRaw("pubsub"u8), + replica = RedisValue.FromRaw("replica"u8), + replica_read_only = RedisValue.FromRaw("replica-read-only"u8), + replication = RedisValue.FromRaw("replication"u8), + sentinel = RedisValue.FromRaw("sentinel"u8), + server = RedisValue.FromRaw("server"u8), + slave = RedisValue.FromRaw("slave"u8), + slave_read_only = RedisValue.FromRaw("slave-read-only"u8), + timeout = RedisValue.FromRaw("timeout"u8), + yes = RedisValue.FromRaw("yes"u8); internal static RedisValue Get(Bitwise operation) => operation switch { diff --git a/src/StackExchange.Redis/RedisResult.cs b/src/StackExchange.Redis/RedisResult.cs index 4a1644c36..78ed649bc 100644 --- a/src/StackExchange.Redis/RedisResult.cs +++ b/src/StackExchange.Redis/RedisResult.cs @@ -3,6 +3,7 @@ using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +using RESPite.Messages; namespace StackExchange.Redis { @@ -102,55 +103,64 @@ public static RedisResult Create(RedisResult[] values, ResultType resultType) public abstract string? ToString(out string? type); /// - /// Internally, this is very similar to RawResult, except it is designed to be usable, - /// outside of the IO-processing pipeline: the buffers are standalone, etc. + /// Designed to be usable outside of the IO-processing pipeline: the buffers are standalone, etc. /// - internal static bool TryCreate(PhysicalConnection? connection, in RawResult result, [NotNullWhen(true)] out RedisResult? redisResult) + internal static bool TryCreate(PhysicalConnection? connection, ref RespReader reader, [NotNullWhen(true)] out RedisResult? redisResult) { + reader.MovePastBof(); try { - switch (result.Resp2TypeBulkString) + var type = reader.Prefix.ToResultType(); + if (reader.Prefix is RespPrefix.Null) { - case ResultType.Integer: - case ResultType.SimpleString: - case ResultType.BulkString: - redisResult = new SingleRedisResult(result.AsRedisValue(), result.Resp3Type); + redisResult = NullSingle; + return true; + } + + if (reader.IsError) + { + redisResult = new ErrorRedisResult(reader.ReadString(), type); + return true; + } + + if (reader.IsScalar) + { + redisResult = new SingleRedisResult(reader.ReadRedisValue(), type); + return true; + } + + if (reader.IsAggregate) + { + if (reader.IsNull) + { + redisResult = new ArrayRedisResult(null, type); return true; - case ResultType.Array: - if (result.IsNull) - { - redisResult = NullArray; - return true; - } - var items = result.GetItems(); - if (items.Length == 0) - { - redisResult = EmptyArray(result.Resp3Type); - return true; - } - var arr = new RedisResult[items.Length]; - int i = 0; - foreach (ref RawResult item in items) + } + + var arr = reader.ReadPastArray( + ref connection, + static (ref PhysicalConnection? conn, ref RespReader r) => { - if (TryCreate(connection, in item, out var next)) - { - arr[i++] = next; - } - else + if (!TryCreate(conn, ref r, out var result)) { - redisResult = null; - return false; + return null!; // Will be caught by null check below } - } - redisResult = new ArrayRedisResult(arr, result.Resp3Type); - return true; - case ResultType.Error: - redisResult = new ErrorRedisResult(result.GetString(), result.Resp3Type); - return true; - default: + return result; + }, + scalar: false); + + if (arr is null || arr.AnyNull()) + { redisResult = null; return false; + } + + redisResult = new ArrayRedisResult(arr, type); + return true; } + + redisResult = null; + return false; } catch (Exception ex) { diff --git a/src/StackExchange.Redis/RedisServer.cs b/src/StackExchange.Redis/RedisServer.cs index 2d7e184ad..2f654d8b9 100644 --- a/src/StackExchange.Redis/RedisServer.cs +++ b/src/StackExchange.Redis/RedisServer.cs @@ -8,7 +8,7 @@ using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; -using Pipelines.Sockets.Unofficial.Arenas; +using RESPite.Messages; namespace StackExchange.Redis { @@ -891,34 +891,39 @@ private protected override Message CreateMessage(in RedisValue cursor) public static readonly ResultProcessor processor = new ScanResultProcessor(); private sealed class ScanResultProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeArray) + if (reader.IsAggregate && reader.AggregateLengthIs(2)) { - case ResultType.Array: - var arr = result.GetItems(); - RawResult inner; - if (arr.Length == 2 && (inner = arr[1]).Resp2TypeArray == ResultType.Array) + // SCAN returns [cursor, [keys...]] + var iter = reader.AggregateChildren(); + if (!iter.MoveNext()) return false; + var cursor = iter.Value.ReadRedisValue(); + + if (iter.MoveNext() && iter.Value.IsAggregate) + { + RedisKey[] keys; + int count; + if (iter.Value.IsNull || iter.Value.AggregateLengthIs(0)) { - var items = inner.GetItems(); - RedisKey[] keys; - int count; - if (items.IsEmpty) - { - keys = Array.Empty(); - count = 0; - } - else + keys = Array.Empty(); + count = 0; + } + else + { + count = iter.Value.AggregateLength(); + keys = ArrayPool.Shared.Rent(count); + var keysIter = iter.Value.AggregateChildren(); + for (int i = 0; i < count; i++) { - count = (int)items.Length; - keys = ArrayPool.Shared.Rent(count); - items.CopyTo(keys, (in RawResult r) => r.AsRedisKey()); + keysIter.DemandNext(); + keys[i] = keysIter.Value.ReadRedisKey(); } - var keysResult = new ScanResult(arr[0].AsRedisValue(), keys, count, true); - SetResult(message, keysResult); - return true; } - break; + var keysResult = new ScanResult(cursor, keys, count, true); + SetResult(message, keysResult); + return true; + } } return false; } @@ -1059,6 +1064,11 @@ public Task ExecuteAsync(int? database, string command, ICollection return ExecuteAsync(msg, ResultProcessor.ScriptResult, defaultValue: RedisResult.NullSingle); } + /// + /// For testing only: Check if the server can simulate connection failure. + /// + internal bool CanSimulateConnectionFailure => server.CanSimulateConnectionFailure; + /// /// For testing only. /// diff --git a/src/StackExchange.Redis/RedisSubscriber.cs b/src/StackExchange.Redis/RedisSubscriber.cs index bd2434771..ef9d42d22 100644 --- a/src/StackExchange.Redis/RedisSubscriber.cs +++ b/src/StackExchange.Redis/RedisSubscriber.cs @@ -3,7 +3,6 @@ using System.Diagnostics.CodeAnalysis; using System.Net; using System.Threading.Tasks; -using Pipelines.Sockets.Unofficial.Arenas; using static StackExchange.Redis.ConnectionMultiplexer; namespace StackExchange.Redis @@ -94,24 +93,6 @@ internal void OnMessage(in RedisChannel subscription, in RedisChannel channel, i } } - internal void OnMessage(in RedisChannel subscription, in RedisChannel channel, Sequence payload) - { - if (payload.IsSingleSegment) - { - foreach (var message in payload.FirstSpan) - { - OnMessage(subscription, channel, message.AsRedisValue()); - } - } - else - { - foreach (var message in payload) - { - OnMessage(subscription, channel, message.AsRedisValue()); - } - } - } - /// /// Updates all subscriptions re-evaluating their state. /// This clears the current server if it's not connected, prepping them to reconnect. diff --git a/src/StackExchange.Redis/RedisTransaction.cs b/src/StackExchange.Redis/RedisTransaction.cs index f0a9600fa..0c369e988 100644 --- a/src/StackExchange.Redis/RedisTransaction.cs +++ b/src/StackExchange.Redis/RedisTransaction.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Text; using System.Threading; using System.Threading.Tasks; +using RESPite.Messages; namespace StackExchange.Redis { @@ -166,6 +168,7 @@ private void QueueMessage(Message message) return Message.Create(-1, flags, RedisCommand.PING); } processor = TransactionProcessor.Default; + return new TransactionMessage(Database, flags, cond, work); } @@ -186,9 +189,9 @@ public bool WasQueued set => wasQueued = value; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { - Wrapped.WriteTo(physical); + Wrapped.WriteTo(writer); Wrapped.SetRequestSent(); } public override int ArgCount => Wrapped.ArgCount; @@ -201,16 +204,21 @@ private sealed class QueuedProcessor : ResultProcessor { public static readonly ResultProcessor Default = new QueuedProcessor(); - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.Resp2TypeBulkString == ResultType.SimpleString && result.IsEqual(CommonReplies.QUEUED)) + if (reader.Prefix == RespPrefix.SimpleString && reader.IsScalar) { - if (message is QueuedMessage q) + Span buffer = stackalloc byte[8]; + var span = reader.TryGetSpan(out var tmp) ? tmp : reader.Buffer(buffer); + if (span.SequenceEqual("QUEUED"u8)) { - connection?.BridgeCouldBeNull?.Multiplexer?.OnTransactionLog("Observed QUEUED for " + q.Wrapped?.CommandAndKey); - q.WasQueued = true; + if (message is QueuedMessage q) + { + connection?.BridgeCouldBeNull?.Multiplexer?.OnTransactionLog("Observed QUEUED for " + q.Wrapped?.CommandAndKey); + q.WasQueued = true; + } + return true; } - return true; } return false; } @@ -320,9 +328,7 @@ public IEnumerable GetMessages(PhysicalConnection connection) sb.AppendLine("checking conditions in the *early* path"); // need to get those sent ASAP; if they are stuck in the buffers, we die multiplexer.Trace("Flushing and waiting for precondition responses"); -#pragma warning disable CS0618 // Type or member is obsolete - connection.FlushSync(true, multiplexer.TimeoutMilliseconds); // make sure they get sent, so we can check for QUEUED (and the preconditions if necessary) -#pragma warning restore CS0618 + connection.Flush(); // make sure they get sent, so we can check for QUEUED (and the preconditions if necessary) if (Monitor.Wait(lastBox, multiplexer.TimeoutMilliseconds)) { @@ -380,9 +386,7 @@ public IEnumerable GetMessages(PhysicalConnection connection) sb.AppendLine("checking conditions in the *late* path"); multiplexer.Trace("Flushing and waiting for precondition+queued responses"); -#pragma warning disable CS0618 // Type or member is obsolete - connection.FlushSync(true, multiplexer.TimeoutMilliseconds); // make sure they get sent, so we can check for QUEUED (and the preconditions if necessary) -#pragma warning restore CS0618 + connection.Flush(); // make sure they get sent, so we can check for QUEUED (and the preconditions if necessary) if (Monitor.Wait(lastBox, multiplexer.TimeoutMilliseconds)) { if (!AreAllConditionsSatisfied(multiplexer)) @@ -441,7 +445,7 @@ public IEnumerable GetMessages(PhysicalConnection connection) } } - protected override void WriteImpl(PhysicalConnection physical) => physical.WriteHeader(Command, 0); + protected override void WriteImpl(in MessageWriter writer) => writer.WriteHeader(Command, 0); public override int ArgCount => 0; @@ -469,11 +473,13 @@ private sealed class TransactionProcessor : ResultProcessor { public static readonly TransactionProcessor Default = new(); - public override bool SetResult(PhysicalConnection connection, Message message, in RawResult result) + public override bool SetResult(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.IsError && message is TransactionMessage tran) + var copy = reader; + reader.MovePastBof(); + if (reader.IsError && message is TransactionMessage tran) { - string error = result.GetString()!; + string error = reader.ReadString()!; foreach (var op in tran.InnerOperations) { var inner = op.Wrapped; @@ -481,78 +487,68 @@ public override bool SetResult(PhysicalConnection connection, Message message, i inner.Complete(); } } - return base.SetResult(connection, message, result); + return base.SetResult(connection, message, ref copy); } - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { var muxer = connection.BridgeCouldBeNull?.Multiplexer; - muxer?.OnTransactionLog($"got {result} for {message.CommandAndKey}"); + muxer?.OnTransactionLog($"got {reader.GetOverview()} for {message.CommandAndKey}"); if (message is TransactionMessage tran) { var wrapped = tran.InnerOperations; - switch (result.Resp2TypeArray) + + if (reader.IsNull) // EXEC returned with a NULL { - case ResultType.SimpleString: - if (tran.IsAborted && result.IsEqual(CommonReplies.OK)) + if (tran.IsAborted) + { + muxer?.OnTransactionLog("Aborting wrapped messages (failed watch)"); + connection.Trace("Server aborted due to failed WATCH"); + foreach (var op in wrapped) { - connection.Trace("Acknowledging UNWATCH (aborted electively)"); - SetResult(message, false); - return true; + var inner = op.Wrapped; + inner.Cancel(); + inner.Complete(); } - // EXEC returned with a NULL - if (!tran.IsAborted && result.IsNull) + } + SetResult(message, false); + return true; + } + if (reader.IsScalar && (tran.IsAborted & reader.IsOK())) + { + connection.Trace("Acknowledging UNWATCH (aborted electively)"); + SetResult(message, false); + return true; + } + + if (reader.IsAggregate && !tran.IsAborted) + { + var len = reader.AggregateLength(); + if (len == wrapped.Length) + { + connection.Trace("Server committed; processing nested replies"); + muxer?.OnTransactionLog($"Processing {len} wrapped messages"); + + var iter = reader.AggregateChildren(); + int i = 0; + // using "raw" to leave reader ahead of content + // (so errors and attributes can be consumed appropriately) + while (iter.MoveNextRaw()) { - connection.Trace("Server aborted due to failed EXEC"); - // cancel the commands in the transaction and mark them as complete with the completion manager - foreach (var op in wrapped) + var inner = wrapped[i++].Wrapped; + muxer?.OnTransactionLog($"> got {iter.Value.GetOverview()} for {inner.CommandAndKey}"); + if (inner.ComputeResult(connection, ref iter.Value)) { - var inner = op.Wrapped; - inner.Cancel(); inner.Complete(); } - SetResult(message, false); - return true; } - break; - case ResultType.Array: - if (!tran.IsAborted) - { - var arr = result.GetItems(); - if (result.IsNull) - { - muxer?.OnTransactionLog("Aborting wrapped messages (failed watch)"); - connection.Trace("Server aborted due to failed WATCH"); - foreach (var op in wrapped) - { - var inner = op.Wrapped; - inner.Cancel(); - inner.Complete(); - } - SetResult(message, false); - return true; - } - else if (wrapped.Length == arr.Length) - { - connection.Trace("Server committed; processing nested replies"); - muxer?.OnTransactionLog($"Processing {arr.Length} wrapped messages"); - int i = 0; - foreach (ref RawResult item in arr) - { - var inner = wrapped[i++].Wrapped; - muxer?.OnTransactionLog($"> got {item} for {inner.CommandAndKey}"); - if (inner.ComputeResult(connection, in item)) - { - inner.Complete(); - } - } - SetResult(message, true); - return true; - } - } - break; + Debug.Assert(i == len, "we pre-checked the lengths"); + SetResult(message, true); + return true; + } } + // even if we didn't fully understand the result, we still need to do something with // the pending tasks foreach (var op in wrapped) diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index 5b8bfe58f..ac9274fcd 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -16,43 +16,150 @@ namespace StackExchange.Redis /// /// Represents values that can be stored in redis. /// + [StructLayout(LayoutKind.Explicit)] public readonly struct RedisValue : IEquatable, IComparable, IComparable, IConvertible { + // Note that this allocates a byte[]; fine when used for a static field from a u8 literal, but + // not a good option to call every time. + internal static RedisValue FromRaw(ReadOnlySpan bytes) => bytes.ToArray(); + internal static readonly RedisValue[] EmptyArray = Array.Empty(); - private readonly object? _objectOrSentinel; - private readonly ReadOnlyMemory _memory; - private readonly long _overlappedBits64; +#pragma warning disable SA1134 + [FieldOffset(0)] private readonly int _index; + [FieldOffset(4)] private readonly int _length; + + // these should only be used if the value of _obj is the appropriate sentinel + [FieldOffset(0)] private readonly long _valueInt64; + [FieldOffset(0)] private readonly ulong _valueUInt64; + [FieldOffset(0)] private readonly double _valueDouble; + + [FieldOffset(8)] private readonly object? _obj; +#pragma warning restore SA1134 + + private RedisValue(byte[]? value) + { + if (value is null) + { + this = default; + } + else if (value.Length == 0) + { + this = EmptyString; + } + else + { + Unsafe.SkipInit(out this); + _index = 0; + _length = value.Length; + _obj = value; + } + } + + private RedisValue(ReadOnlyMemory value) + { + Unsafe.SkipInit(out this); + if (value.IsEmpty) + { + this = EmptyString; + } + else if (MemoryMarshal.TryGetArray(value, out var segment)) + { + _index = segment.Offset; + _length = segment.Count; + _obj = segment.Array; + } + else if (MemoryMarshal.TryGetMemoryManager>(value, out var manager, out var index, out var length)) + { + _index = index; + _length = length; + _obj = manager; + } + else + { + Throw(); + static void Throw() => throw new ArgumentException("Unrecognized memory type"); + } + } - private RedisValue(long overlappedValue64, ReadOnlyMemory memory, object? objectOrSentinel) + private RedisValue(long value) { - _overlappedBits64 = overlappedValue64; - _memory = memory; - _objectOrSentinel = objectOrSentinel; + Unsafe.SkipInit(out this); + _valueInt64 = value; + _obj = Sentinel_SignedInteger; } - internal RedisValue(object obj, long overlappedBits) + private RedisValue(ulong value) { - // this creates a bodged RedisValue which should **never** - // be seen directly; the contents are ... unexpected - _overlappedBits64 = overlappedBits; - _objectOrSentinel = obj; - _memory = default; + Unsafe.SkipInit(out this); + if (value <= long.MaxValue) + { + _valueInt64 = (long)value; + _obj = Sentinel_SignedInteger; + } + else + { + _valueUInt64 = value; + _obj = Sentinel_UnsignedInteger; + } + } + + private RedisValue(double value) + { + Unsafe.SkipInit(out this); + try + { + var i64 = (long)value; + // note: double doesn't offer integer accuracy at 64 bits, so we know it can't be unsigned (only use that for 64-bit) + // ReSharper disable once CompareOfFloatsByEqualityOperator + if (value == i64) + { + _valueInt64 = i64; + _obj = Sentinel_SignedInteger; + return; + } + } + catch + { + // ignored + } + + _valueDouble = value; + _obj = Sentinel_Double; } /// /// Creates a from a string. /// - public RedisValue(string value) : this(0, default, value) { } + public RedisValue(string value) + { + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (value is null) + { + // I have trust issues + this = default; + } + else + { + Unsafe.SkipInit(out this); + _index = 0; + _length = value.Length; + _obj = value; + } + } + +#pragma warning disable RCS1085 // use auto-prop + // ReSharper disable ConvertToAutoProperty + internal double OverlappedValueDouble => _valueDouble; + + internal long OverlappedValueInt64 => _valueInt64; - [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1085:Use auto-implemented property.", Justification = "Intentional field ref")] - internal object? DirectObject => _objectOrSentinel; - [System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1085:Use auto-implemented property.", Justification = "Intentional field ref")] - internal long DirectOverlappedBits64 => _overlappedBits64; + internal ulong OverlappedValueUInt64 => _valueUInt64; + // ReSharper restore ConvertToAutoProperty +#pragma warning restore RCS1085 // use auto-prop private static readonly object Sentinel_SignedInteger = new(); private static readonly object Sentinel_UnsignedInteger = new(); - private static readonly object Sentinel_Raw = new(); private static readonly object Sentinel_Double = new(); /// @@ -60,8 +167,8 @@ public RedisValue(string value) : this(0, default, value) { } /// public object? Box() { - var obj = _objectOrSentinel; - if (obj is null || obj is string || obj is byte[]) return obj; + var obj = _obj; + if (obj is null || obj is string || (obj is byte[] b && _index == 0 && _length == b.Length)) return obj; if (obj == Sentinel_SignedInteger) { var l = OverlappedValueInt64; @@ -80,7 +187,6 @@ public RedisValue(string value) : this(0, default, value) { } if (double.IsNaN(d)) return s_DoubleNAN; return d; } - if (obj == Sentinel_Raw && _memory.IsEmpty) return s_EmptyString; return this; } @@ -98,7 +204,7 @@ public static RedisValue Unbox(object? value) /// /// Represents the string "". /// - public static RedisValue EmptyString { get; } = new RedisValue(0, default, Sentinel_Raw); + public static RedisValue EmptyString { get; } = new(""); // note: it is *really important* that this s_EmptyString assignment happens *after* the EmptyString initializer above! private static readonly object s_DoubleNAN = double.NaN, s_DoublePosInf = double.PositiveInfinity, s_DoubleNegInf = double.NegativeInfinity, @@ -108,7 +214,7 @@ public static RedisValue Unbox(object? value) /// /// A null value. /// - public static RedisValue Null { get; } = new RedisValue(0, default, null); + public static RedisValue Null { get; } = default; /// /// Indicates whether the **underlying** value is a primitive integer (signed or unsigned); this is **not** @@ -116,12 +222,12 @@ public static RedisValue Unbox(object? value) /// and , which is usually the more appropriate test. /// [Browsable(false), EditorBrowsable(EditorBrowsableState.Advanced)] // hide it, because this *probably* isn't what callers need - public bool IsInteger => _objectOrSentinel == Sentinel_SignedInteger || _objectOrSentinel == Sentinel_UnsignedInteger; + public bool IsInteger => _obj == Sentinel_SignedInteger || _obj == Sentinel_UnsignedInteger; /// /// Indicates whether the value should be considered a null value. /// - public bool IsNull => _objectOrSentinel == null; + public bool IsNull => _obj is null; /// /// Indicates whether the value is either null or a zero-length value. @@ -130,11 +236,10 @@ public bool IsNullOrEmpty { get { - if (IsNull) return true; - if (_objectOrSentinel == Sentinel_Raw && _memory.IsEmpty) return true; - if (_objectOrSentinel is string s && s.Length == 0) return true; - if (_objectOrSentinel is byte[] arr && arr.Length == 0) return true; - return false; + // primitives are never null + if (_obj == Sentinel_Double || _obj == Sentinel_SignedInteger || _obj == Sentinel_UnsignedInteger) return false; + // everything else either null or a buffer or some kind; can use length + return _length == 0; } } @@ -150,23 +255,22 @@ public bool IsNullOrEmpty /// The second to compare. public static bool operator !=(RedisValue x, RedisValue y) => !(x == y); - internal double OverlappedValueDouble + internal ReadOnlySpan RawSpan() { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => BitConverter.Int64BitsToDouble(_overlappedBits64); + if (_obj is byte[] b) return new ReadOnlySpan(b, _index, _length); + if (_obj is MemoryManager m) return m.GetSpan().Slice(_index, _length); + ThrowRawType(); + return default; } - internal long OverlappedValueInt64 + internal string RawString() { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => _overlappedBits64; + if (_obj is string s) return s; + ThrowRawType(); + return ""; } - internal ulong OverlappedValueUInt64 - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => unchecked((ulong)_overlappedBits64); - } + private static void ThrowRawType() => throw new InvalidOperationException("Invalid raw operation."); /// /// Indicates whether two RedisValue values are equivalent. @@ -187,14 +291,15 @@ internal ulong OverlappedValueUInt64 switch (xType) { case StorageType.Double: // make sure we use double equality rules + // ReSharper disable once CompareOfFloatsByEqualityOperator return x.OverlappedValueDouble == y.OverlappedValueDouble; case StorageType.Int64: case StorageType.UInt64: // as long as xType == yType, only need to check the bits - return x._overlappedBits64 == y._overlappedBits64; + return x._valueInt64 == y._valueInt64; case StorageType.String: - return (string?)x._objectOrSentinel == (string?)y._objectOrSentinel; - case StorageType.Raw: - return x._memory.Span.SequenceEqual(y._memory.Span); + return x.RawString() == y.RawString(); + case StorageType.ByteArray or StorageType.MemoryManager: + return x.RawSpan().SequenceEqual(y.RawSpan()); } } @@ -246,9 +351,9 @@ private static int GetHashCode(RedisValue x) { StorageType.Null => -1, StorageType.Double => x.OverlappedValueDouble.GetHashCode(), - StorageType.Int64 or StorageType.UInt64 => x._overlappedBits64.GetHashCode(), - StorageType.Raw => ((string)x!).GetHashCode(), // to match equality - _ => x._objectOrSentinel!.GetHashCode(), + StorageType.Int64 or StorageType.UInt64 => x._valueInt64.GetHashCode(), + StorageType.String => x.RawString().GetHashCode(), + _ => ((string)x!).GetHashCode(), // to match equality }; } @@ -317,24 +422,59 @@ internal enum StorageType Int64, UInt64, Double, - Raw, + MemoryManager, + ByteArray, String, + Unknown, } internal StorageType Type { get { - var objectOrSentinel = _objectOrSentinel; - if (objectOrSentinel == null) return StorageType.Null; - if (objectOrSentinel == Sentinel_SignedInteger) return StorageType.Int64; - if (objectOrSentinel == Sentinel_Double) return StorageType.Double; - if (objectOrSentinel == Sentinel_Raw) return StorageType.Raw; - if (objectOrSentinel is string) return StorageType.String; - if (objectOrSentinel is byte[]) return StorageType.Raw; // doubled-up, but retaining the array - if (objectOrSentinel == Sentinel_UnsignedInteger) return StorageType.UInt64; - throw new InvalidOperationException("Unknown type"); + var obj = _obj; + if (obj is null) return StorageType.Null; + if (obj == Sentinel_SignedInteger) return StorageType.Int64; + if (obj == Sentinel_Double) return StorageType.Double; + if (obj is string) return StorageType.String; + if (obj is byte[]) return StorageType.ByteArray; + if (obj == Sentinel_UnsignedInteger) return StorageType.UInt64; + if (obj is MemoryManager) return StorageType.MemoryManager; + return StorageType.Unknown; + } + } + + // used in the toy server only! + internal static RedisValue CreateForeign(T value, int index, int length) where T : class + { + if (typeof(T) == typeof(string) || typeof(T) == typeof(byte[])) Throw(); + return new RedisValue(value, index, length); + static void Throw() => throw new InvalidOperationException(); + } + + private RedisValue(object obj, int index, int length) + { + Unsafe.SkipInit(out this); + _index = index; + _length = length; + _obj = obj; + } + + // used in the toy server only! + 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[]) && _obj is T found) + { + index = _index; + length = _length; + value = found; + return true; } + value = null; + index = 0; + length = 0; + return false; } /// @@ -343,8 +483,8 @@ internal StorageType Type public long Length() => Type switch { StorageType.Null => 0, - StorageType.Raw => _memory.Length, - StorageType.String => Encoding.UTF8.GetByteCount((string)_objectOrSentinel!), + StorageType.MemoryManager or StorageType.ByteArray => _length, + StorageType.String => Encoding.UTF8.GetByteCount(RawString()), StorageType.Int64 => Format.MeasureInt64(OverlappedValueInt64), StorageType.UInt64 => Format.MeasureUInt64(OverlappedValueUInt64), StorageType.Double => Format.MeasureDouble(OverlappedValueDouble), @@ -379,9 +519,9 @@ private static int CompareTo(RedisValue x, RedisValue y) case StorageType.UInt64: return x.OverlappedValueUInt64.CompareTo(y.OverlappedValueUInt64); case StorageType.String: - return string.CompareOrdinal((string)x._objectOrSentinel!, (string)y._objectOrSentinel!); - case StorageType.Raw: - return x._memory.Span.SequenceCompareTo(y._memory.Span); + return string.CompareOrdinal(x.RawString(), y.RawString()); + case StorageType.MemoryManager or StorageType.ByteArray: + return x.RawSpan().SequenceCompareTo(y.RawSpan()); } } @@ -450,75 +590,59 @@ internal static RedisValue TryParse(object? obj, out bool valid) /// Creates a new from an . /// /// The to convert to a . - public static implicit operator RedisValue(int value) => new RedisValue(value, default, Sentinel_SignedInteger); + public static implicit operator RedisValue(int value) => new(value); /// /// Creates a new from an . /// /// The to convert to a . - public static implicit operator RedisValue(int? value) => value == null ? Null : (RedisValue)value.GetValueOrDefault(); + public static implicit operator RedisValue(int? value) => value == null ? Null : new(value.GetValueOrDefault()); /// /// Creates a new from an . /// /// The to convert to a . - public static implicit operator RedisValue(long value) => new RedisValue(value, default, Sentinel_SignedInteger); + public static implicit operator RedisValue(long value) => new(value); /// /// Creates a new from an . /// /// The to convert to a . - public static implicit operator RedisValue(long? value) => value == null ? Null : (RedisValue)value.GetValueOrDefault(); + public static implicit operator RedisValue(long? value) => value == null ? Null : new(value.GetValueOrDefault()); /// /// Creates a new from an . /// /// The to convert to a . [CLSCompliant(false)] - public static implicit operator RedisValue(ulong value) - { - const ulong MSB = 1UL << 63; - return (value & MSB) == 0 - ? new RedisValue((long)value, default, Sentinel_SignedInteger) // prefer signed whenever we can - : new RedisValue(unchecked((long)value), default, Sentinel_UnsignedInteger); // with unsigned as the fallback - } + public static implicit operator RedisValue(ulong value) => new(value); /// /// Creates a new from an . /// /// The to convert to a . [CLSCompliant(false)] - public static implicit operator RedisValue(ulong? value) => value == null ? Null : (RedisValue)value.GetValueOrDefault(); + public static implicit operator RedisValue(ulong? value) => value == null ? Null : new(value.GetValueOrDefault()); /// /// Creates a new from an . /// /// The to convert to a . [CLSCompliant(false)] - public static implicit operator RedisValue(uint value) => new RedisValue(value, default, Sentinel_SignedInteger); // 32-bits always fits as signed + public static implicit operator RedisValue(uint value) => new(value); /// /// Creates a new from an . /// /// The to convert to a . [CLSCompliant(false)] - public static implicit operator RedisValue(uint? value) => value == null ? Null : (RedisValue)value.GetValueOrDefault(); + public static implicit operator RedisValue(uint? value) => value == null ? Null : new(value.GetValueOrDefault()); /// /// Creates a new from an . /// /// The to convert to a . - public static implicit operator RedisValue(double value) - { - try - { - var i64 = (long)value; - // note: double doesn't offer integer accuracy at 64 bits, so we know it can't be unsigned (only use that for 64-bit) - if (value == i64) return new RedisValue(i64, default, Sentinel_SignedInteger); - } - catch { } - return new RedisValue(BitConverter.DoubleToInt64Bits(value), default, Sentinel_Double); - } + public static implicit operator RedisValue(double value) => new(value); /// /// Creates a new from an . @@ -530,51 +654,38 @@ public static implicit operator RedisValue(double value) /// Creates a new from a . /// /// The to convert to a . - public static implicit operator RedisValue(ReadOnlyMemory value) - { - if (value.Length == 0) return EmptyString; - return new RedisValue(0, value, Sentinel_Raw); - } + public static implicit operator RedisValue(ReadOnlyMemory value) => new(value); /// /// Creates a new from a . /// /// The to convert to a . - public static implicit operator RedisValue(Memory value) => (ReadOnlyMemory)value; + public static implicit operator RedisValue(Memory value) => new(value); /// /// Creates a new from an . /// /// The to convert to a . - public static implicit operator RedisValue(string? value) - { - if (value == null) return Null; - if (value.Length == 0) return EmptyString; - return new RedisValue(0, default, value); - } + public static implicit operator RedisValue(string? value) => value is null ? Null : new(value); /// /// Creates a new from an . /// /// The to convert to a . - public static implicit operator RedisValue(byte[]? value) - { - if (value == null) return Null; - if (value.Length == 0) return EmptyString; - return new RedisValue(0, new Memory(value), value); - } + public static implicit operator RedisValue(byte[]? value) => new(value); /// /// Creates a new from an . /// /// The to convert to a . - public static implicit operator RedisValue(bool value) => new RedisValue(value ? 1 : 0, default, Sentinel_SignedInteger); + public static implicit operator RedisValue(bool value) => new RedisValue(value ? 1 : 0); /// /// Creates a new from an . /// /// The to convert to a . - public static implicit operator RedisValue(bool? value) => value == null ? Null : (RedisValue)value.GetValueOrDefault(); + public static implicit operator RedisValue(bool? value) => value == null ? Null + : new(value.GetValueOrDefault() ? 1 : 0); /// /// Converts a to a . @@ -658,8 +769,8 @@ public static explicit operator double(RedisValue value) StorageType.UInt64 => value.OverlappedValueUInt64, StorageType.Double => value.OverlappedValueDouble, // special values like NaN/Inf are deliberately not handled by Simplify, but need to be considered for casting - StorageType.String when Format.TryParseDouble((string)value._objectOrSentinel!, out var d) => d, - StorageType.Raw when TryParseDouble(value._memory.Span, out var d) => d, + StorageType.String when Format.TryParseDouble(value.RawString(), out var d) => d, + StorageType.MemoryManager or StorageType.ByteArray when TryParseDouble(value.RawSpan(), out var d) => d, // anything else: fail _ => throw new InvalidCastException($"Unable to cast from {value.Type} to double: '{value}'"), }; @@ -781,9 +892,9 @@ private static bool TryParseDouble(ReadOnlySpan blob, out double value) case StorageType.Double: return Format.ToString(value.OverlappedValueDouble); case StorageType.Int64: return Format.ToString(value.OverlappedValueInt64); case StorageType.UInt64: return Format.ToString(value.OverlappedValueUInt64); - case StorageType.String: return (string)value._objectOrSentinel!; - case StorageType.Raw: - var span = value._memory.Span; + case StorageType.String: return value.RawString(); + case StorageType.MemoryManager or StorageType.ByteArray: + var span = value.RawSpan(); if (span.IsEmpty) return ""; if (span.Length == 2 && span[0] == (byte)'O' && span[1] == (byte)'K') return "OK"; // frequent special-case try @@ -835,17 +946,11 @@ private static string ToHex(ReadOnlySpan src) switch (value.Type) { case StorageType.Null: return null; - case StorageType.Raw: - if (value._objectOrSentinel is byte[] arr) return arr; - - if (MemoryMarshal.TryGetArray(value._memory, out var segment) - && segment.Offset == 0 - && segment.Count == (segment.Array?.Length ?? -1)) - { - return segment.Array; // the memory is backed by an array, and we're reading all of it - } - - return value._memory.ToArray(); + case StorageType.ByteArray when value._obj is byte[] arr && value._index is 0 && value._length == arr.Length: + // the memory is backed by an array, and we're reading all of it + return arr; + case StorageType.ByteArray or StorageType.MemoryManager: + return value.RawSpan().ToArray(); case StorageType.Int64: Debug.Assert(Format.MaxInt64TextLen <= 24); Span span = stackalloc byte[24]; @@ -861,7 +966,7 @@ private static string ToHex(ReadOnlySpan src) len = Format.FormatDouble(value.OverlappedValueDouble, span); return span.Slice(0, len).ToArray(); case StorageType.String: - return Encoding.UTF8.GetBytes((string)value._objectOrSentinel!); + return Encoding.UTF8.GetBytes(value.RawString()); } // fallback: stringify and encode return Encoding.UTF8.GetBytes((string)value!); @@ -873,8 +978,8 @@ private static string ToHex(ReadOnlySpan src) public int GetByteCount() => Type switch { StorageType.Null => 0, - StorageType.Raw => _memory.Length, - StorageType.String => Encoding.UTF8.GetByteCount((string)_objectOrSentinel!), + StorageType.MemoryManager or StorageType.ByteArray => _length, + StorageType.String => Encoding.UTF8.GetByteCount(RawString()), StorageType.Int64 => Format.MeasureInt64(OverlappedValueInt64), StorageType.UInt64 => Format.MeasureUInt64(OverlappedValueUInt64), StorageType.Double => Format.MeasureDouble(OverlappedValueDouble), @@ -887,8 +992,8 @@ private static string ToHex(ReadOnlySpan src) internal int GetMaxByteCount() => Type switch { StorageType.Null => 0, - StorageType.Raw => _memory.Length, - StorageType.String => Encoding.UTF8.GetMaxByteCount(((string)_objectOrSentinel!).Length), + StorageType.MemoryManager or StorageType.ByteArray => _length, + StorageType.String => Encoding.UTF8.GetMaxByteCount(RawString().Length), StorageType.Int64 => Format.MaxInt64TextLen, StorageType.UInt64 => Format.MaxInt64TextLen, StorageType.Double => Format.MaxDoubleTextLen, @@ -901,8 +1006,8 @@ private static string ToHex(ReadOnlySpan src) internal int GetCharCount() => Type switch { StorageType.Null => 0, - StorageType.Raw => Encoding.UTF8.GetCharCount(_memory.Span), - StorageType.String => ((string)_objectOrSentinel!).Length, + StorageType.MemoryManager or StorageType.ByteArray => Encoding.UTF8.GetCharCount(RawSpan()), + StorageType.String => _length, StorageType.Int64 => Format.MeasureInt64(OverlappedValueInt64), StorageType.UInt64 => Format.MeasureUInt64(OverlappedValueUInt64), StorageType.Double => Format.MeasureDouble(OverlappedValueDouble), @@ -915,8 +1020,8 @@ private static string ToHex(ReadOnlySpan src) internal int GetMaxCharCount() => Type switch { StorageType.Null => 0, - StorageType.Raw => Encoding.UTF8.GetMaxCharCount(_memory.Length), - StorageType.String => ((string)_objectOrSentinel!).Length, + StorageType.MemoryManager or StorageType.ByteArray => Encoding.UTF8.GetMaxCharCount(_length), + StorageType.String => _length, StorageType.Int64 => Format.MaxInt64TextLen, StorageType.UInt64 => Format.MaxInt64TextLen, StorageType.Double => Format.MaxDoubleTextLen, @@ -941,12 +1046,11 @@ public int CopyTo(Span destination) { case StorageType.Null: return 0; - case StorageType.Raw: - var srcBytes = _memory.Span; - srcBytes.CopyTo(destination); - return srcBytes.Length; + case StorageType.MemoryManager or StorageType.ByteArray: + RawSpan().CopyTo(destination); + return _length; case StorageType.String: - return Encoding.UTF8.GetBytes(((string)_objectOrSentinel!).AsSpan(), destination); + return Encoding.UTF8.GetBytes(RawString().AsSpan(), destination); case StorageType.Int64: return Format.FormatInt64(OverlappedValueInt64, destination); case StorageType.UInt64: @@ -967,11 +1071,10 @@ internal int CopyTo(Span destination) { case StorageType.Null: return 0; - case StorageType.Raw: - var srcBytes = _memory.Span; - return Encoding.UTF8.GetChars(srcBytes, destination); + case StorageType.MemoryManager or StorageType.ByteArray: + return Encoding.UTF8.GetChars(RawSpan(), destination); case StorageType.String: - var span = ((string)_objectOrSentinel!).AsSpan(); + var span = RawString().AsSpan(); span.CopyTo(destination); return span.Length; case StorageType.Int64: @@ -990,7 +1093,12 @@ internal int CopyTo(Span destination) /// /// The to convert. public static implicit operator ReadOnlyMemory(RedisValue value) - => value.Type == StorageType.Raw ? value._memory : (byte[]?)value; + { + if (value._obj is byte[] arr) return new ReadOnlyMemory(arr, value._index, value._length); + if (value._obj is MemoryManager manager) return manager.Memory.Slice(value._index, value._length); + if (value._obj is null) return default; + return (byte[]?)value; + } TypeCode IConvertible.GetTypeCode() => TypeCode.Object; @@ -1054,7 +1162,7 @@ internal RedisValue Simplify() switch (Type) { case StorageType.String: - string s = (string)_objectOrSentinel!; + string s = RawString(); if (Format.CouldBeInteger(s)) { if (Format.TryParseInt64(s, out i64)) return i64; @@ -1063,8 +1171,8 @@ internal RedisValue Simplify() // note: don't simplify inf/nan, as that causes equality semantic problems if (Format.TryParseDouble(s, out var f64) && !IsSpecialDouble(f64)) return f64; break; - case StorageType.Raw: - var b = _memory.Span; + case StorageType.MemoryManager or StorageType.ByteArray: + var b = RawSpan(); if (Format.CouldBeInteger(b)) { if (Format.TryParseInt64(b, out i64)) return i64; @@ -1101,9 +1209,9 @@ public bool TryParse(out long val) val = default; return false; case StorageType.String: - return Format.TryParseInt64((string)_objectOrSentinel!, out val); - case StorageType.Raw: - return Format.TryParseInt64(_memory.Span, out val); + return Format.TryParseInt64(RawString(), out val); + case StorageType.MemoryManager or StorageType.ByteArray: + return Format.TryParseInt64(RawSpan(), out val); case StorageType.Double: var d = OverlappedValueDouble; try @@ -1161,9 +1269,9 @@ public bool TryParse(out double val) val = OverlappedValueDouble; return true; case StorageType.String: - return Format.TryParseDouble((string)_objectOrSentinel!, out val); - case StorageType.Raw: - return TryParseDouble(_memory.Span, out val); + return Format.TryParseDouble(RawString(), out val); + case StorageType.MemoryManager or StorageType.ByteArray: + return TryParseDouble(RawSpan(), out val); case StorageType.Null: // in redis-land 0 approx. equal null; so roll with it val = 0; @@ -1224,27 +1332,24 @@ public bool StartsWith(RedisValue value) if (value.IsNullOrEmpty) return true; if (IsNullOrEmpty) return false; - ReadOnlyMemory rawThis, rawOther; var thisType = Type; if (thisType == value.Type) // same? can often optimize { switch (thisType) { case StorageType.String: - var sThis = (string)_objectOrSentinel!; - var sOther = (string)value._objectOrSentinel!; + var sThis = RawString(); + var sOther = value.RawString(); return sThis.StartsWith(sOther, StringComparison.Ordinal); - case StorageType.Raw: - rawThis = _memory; - rawOther = value._memory; - return rawThis.Span.StartsWith(rawOther.Span); + case StorageType.MemoryManager or StorageType.ByteArray: + return RawSpan().StartsWith(value.RawSpan()); } } byte[]? arr0 = null, arr1 = null; try { - rawThis = AsMemory(out arr0); - rawOther = value.AsMemory(out arr1); + var rawThis = AsMemory(out arr0); + var rawOther = value.AsMemory(out arr1); return rawThis.Span.StartsWith(rawOther.Span); } @@ -1259,11 +1364,14 @@ private ReadOnlyMemory AsMemory(out byte[]? leased) { switch (Type) { - case StorageType.Raw: + case StorageType.MemoryManager: leased = null; - return _memory; + return ((MemoryManager)_obj!).Memory.Slice(_index, _length); + case StorageType.ByteArray: + leased = null; + return new ReadOnlyMemory((byte[])_obj!, _index, _length); case StorageType.String: - string s = (string)_objectOrSentinel!; + string s = RawString(); HaveString: if (s.Length == 0) { @@ -1278,7 +1386,7 @@ private ReadOnlyMemory AsMemory(out byte[]? leased) goto HaveString; case StorageType.Int64: leased = ArrayPool.Shared.Rent(Format.MaxInt64TextLen + 2); // reused code has CRLF terminator - len = PhysicalConnection.WriteRaw(leased, OverlappedValueInt64) - 2; // drop the CRLF + len = MessageWriter.WriteRaw(leased, OverlappedValueInt64) - 2; // drop the CRLF return new ReadOnlyMemory(leased, 0, len); case StorageType.UInt64: leased = ArrayPool.Shared.Rent(Format.MaxInt64TextLen); // reused code has CRLF terminator @@ -1298,8 +1406,8 @@ internal ValueCondition Digest() { switch (Type) { - case StorageType.Raw: - return ValueCondition.CalculateDigest(_memory.Span); + case StorageType.MemoryManager or StorageType.ByteArray: + return ValueCondition.CalculateDigest(RawSpan()); case StorageType.Null: return ValueCondition.NotExists; // interpret === null as "not exists" default: @@ -1315,9 +1423,14 @@ internal ValueCondition Digest() internal bool TryGetSpan(out ReadOnlySpan span) { - if (_objectOrSentinel == Sentinel_Raw) + if (_obj is MemoryManager manager) + { + span = manager.Memory.Span.Slice(_index, _length); + return true; + } + if (_obj is byte[] bytes) { - span = _memory.Span; + span = new ReadOnlySpan(bytes, _index, _length); return true; } span = default; @@ -1338,8 +1451,8 @@ public bool StartsWith(ReadOnlySpan value) int len; switch (Type) { - case StorageType.Raw: - return _memory.Span.StartsWith(value); + case StorageType.MemoryManager or StorageType.ByteArray: + return RawSpan().StartsWith(value); case StorageType.Int64: Span buffer = stackalloc byte[Format.MaxInt64TextLen]; len = Format.FormatInt64(OverlappedValueInt64, buffer); @@ -1353,7 +1466,7 @@ public bool StartsWith(ReadOnlySpan value) len = Format.FormatDouble(OverlappedValueDouble, buffer); return buffer.Slice(0, len).StartsWith(value); case StorageType.String: - var s = ((string)_objectOrSentinel!).AsSpan(); + var s = RawString().AsSpan(); if (s.Length < value.Length) return false; // not enough characters to match if (s.Length > value.Length) s = s.Slice(0, value.Length); // only need to match the prefix var maxBytes = Encoding.UTF8.GetMaxByteCount(s.Length); @@ -1368,31 +1481,5 @@ 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/ReplicationState.cs b/src/StackExchange.Redis/ReplicationState.cs new file mode 100644 index 000000000..e0194954e --- /dev/null +++ b/src/StackExchange.Redis/ReplicationState.cs @@ -0,0 +1,62 @@ +using System; +using RESPite; + +namespace StackExchange.Redis; + +/// +/// Redis replication states. +/// +// [AsciiHash(nameof(ReplicationStateMetadata))] +internal enum ReplicationState +{ + /// + /// Unknown or unrecognized state. + /// + [AsciiHash("")] + Unknown = 0, + + /// + /// Connect state. + /// + [AsciiHash("connect")] + Connect, + + /// + /// Connecting state. + /// + [AsciiHash("connecting")] + Connecting, + + /// + /// Sync state. + /// + [AsciiHash("sync")] + Sync, + + /// + /// Connected state. + /// + [AsciiHash("connected")] + Connected, + + /// + /// None state. + /// + [AsciiHash("none")] + None, + + /// + /// Handshake state. + /// + [AsciiHash("handshake")] + Handshake, +} + +/// +/// Metadata and parsing methods for ReplicationState. +/// +internal static partial class ReplicationStateMetadata +{ + [AsciiHash] + internal static partial bool TryParse(ReadOnlySpan value, out ReplicationState state); +} diff --git a/toys/StackExchange.Redis.Server/RespReaderExtensions.cs b/src/StackExchange.Redis/RespReaderExtensions.cs similarity index 92% rename from toys/StackExchange.Redis.Server/RespReaderExtensions.cs rename to src/StackExchange.Redis/RespReaderExtensions.cs index 8ee5f921c..f5e2e36c5 100644 --- a/toys/StackExchange.Redis.Server/RespReaderExtensions.cs +++ b/src/StackExchange.Redis/RespReaderExtensions.cs @@ -1,11 +1,9 @@ -#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 +namespace StackExchange.Redis; internal static class RespReaderExtensions { @@ -20,8 +18,6 @@ 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(), }; } @@ -139,15 +135,15 @@ public void MovePastBof() public RedisValue[]? ReadPastRedisValues() => reader.ReadPastArray(static (ref r) => r.ReadRedisValue(), scalar: true); - public seredis::StackExchange.Redis.Lease? AsLease() + public 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; + if (length == 0) return Lease.Empty; - var lease = seredis::StackExchange.Redis.Lease.Create(length, clear: false); + var lease = Lease.Create(length, clear: false); if (reader.TryGetSpan(out var span)) { span.CopyTo(lease.Span); @@ -204,7 +200,7 @@ internal bool AnyNull() } } -#if !NET +#if !(NET || NETSTANDARD2_1_OR_GREATER) extension(Task task) { public bool IsCompletedSuccessfully => task.Status is TaskStatus.RanToCompletion; diff --git a/src/StackExchange.Redis/ResultProcessor.Digest.cs b/src/StackExchange.Redis/ResultProcessor.Digest.cs index 757009ea5..a0093fbc8 100644 --- a/src/StackExchange.Redis/ResultProcessor.Digest.cs +++ b/src/StackExchange.Redis/ResultProcessor.Digest.cs @@ -1,5 +1,4 @@ -using System; -using System.Buffers; +using RESPite.Messages; namespace StackExchange.Redis; @@ -11,28 +10,18 @@ internal abstract partial class ResultProcessor private sealed class DigestProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.IsNull) // for example, key doesn't exist + if (reader.IsNull) // for example, key doesn't exist { SetResult(message, null); return true; } - if (result.Resp2TypeBulkString == ResultType.BulkString - && result.Payload is { Length: 2 * ValueCondition.DigestBytes } payload) + if (reader.ScalarLengthIs(2 * ValueCondition.DigestBytes)) { - ValueCondition digest; - if (payload.IsSingleSegment) // single chunk - fast path - { - digest = ValueCondition.ParseDigest(payload.First.Span); - } - else // linearize - { - Span buffer = stackalloc byte[2 * ValueCondition.DigestBytes]; - payload.CopyTo(buffer); - digest = ValueCondition.ParseDigest(buffer); - } + var span = reader.TryGetSpan(out var tmp) ? tmp : reader.Buffer(stackalloc byte[2 * ValueCondition.DigestBytes]); + var digest = ValueCondition.ParseDigest(span); SetResult(message, digest); return true; } diff --git a/src/StackExchange.Redis/ResultProcessor.Lease.cs b/src/StackExchange.Redis/ResultProcessor.Lease.cs index c0f9e6d8e..91dfcd2ad 100644 --- a/src/StackExchange.Redis/ResultProcessor.Lease.cs +++ b/src/StackExchange.Redis/ResultProcessor.Lease.cs @@ -1,5 +1,4 @@ -using System.Diagnostics; -using Pipelines.Sockets.Unofficial.Arenas; +using RESPite.Messages; // ReSharper disable once CheckNamespace namespace StackExchange.Redis; @@ -17,181 +16,144 @@ public static readonly ResultProcessor> private abstract class LeaseProcessor : ResultProcessor?> { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.Resp2TypeArray != ResultType.Array) + if (!reader.IsAggregate) { return false; // not an array } // deal with null - if (result.IsNull) + if (reader.IsNull) { - SetResult(message, Lease.Empty); + SetResult(message, null); return true; } // lease and fill - var items = result.GetItems(); - var length = checked((int)items.Length); - var lease = Lease.Create(length, clear: false); // note this handles zero nicely - var target = lease.Span; - int index = 0; - foreach (ref RawResult item in items) - { - if (!TryParse(item, out target[index++])) - { - // something went wrong; recycle and quit - lease.Dispose(); - return false; - } - } - Debug.Assert(index == length, "length mismatch"); - SetResult(message, lease); - return true; - } - - protected abstract bool TryParse(in RawResult raw, out T parsed); - } - - private abstract class InterleavedLeaseProcessor : ResultProcessor?> - { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) - { - if (result.Resp2TypeArray != ResultType.Array) + var length = reader.AggregateLength(); + if (length == 0) { - return false; // not an array + SetResult(message, Lease.Empty); + return true; } - // deal with null - if (result.IsNull) + var lease = Lease.Create(length, clear: false); + try { - SetResult(message, Lease.Empty); + var self = this; + reader.FillAll(lease.Span, ref self, static (ref s, ref r) => s.TryParse(ref r)); + SetResult(message, lease); return true; } - - // lease and fill - var items = result.GetItems(); - var length = checked((int)items.Length) / 2; - var lease = Lease.Create(length, clear: false); // note this handles zero nicely - var target = lease.Span; - - var iter = items.GetEnumerator(); - for (int i = 0; i < target.Length; i++) + catch { - bool ok = iter.MoveNext(); - if (ok) - { - ref readonly RawResult first = ref iter.Current; - ok = iter.MoveNext() && TryParse(in first, in iter.Current, out target[i]); - } - if (!ok) - { - lease.Dispose(); - return false; - } + // something went wrong; recycle and quit + lease.Dispose(); + throw; } - SetResult(message, lease); - return true; } - protected abstract bool TryParse(in RawResult first, in RawResult second, out T parsed); + protected abstract T TryParse(ref RespReader reader); } // takes a nested vector of the form [[A],[B,C],[D]] and exposes it as [A,B,C,D]; this is // especially useful for VLINKS private abstract class FlattenedLeaseProcessor : ResultProcessor?> { - protected virtual long GetArrayLength(in RawResult array) => array.GetItems().Length; + protected virtual long GetArrayLength(in RespReader reader) => reader.AggregateLength(); - protected virtual bool TryReadOne(ref Sequence.Enumerator reader, out T value) - { - if (reader.MoveNext()) - { - return TryReadOne(in reader.Current, out value); - } - value = default!; - return false; - } - - protected virtual bool TryReadOne(in RawResult result, out T value) + protected virtual bool TryReadOne(ref RespReader reader, out T value) { value = default!; return false; } - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.Resp2TypeArray != ResultType.Array) + if (!reader.IsAggregate) { return false; // not an array } - if (result.IsNull) + + // deal with null + if (reader.IsNull) { SetResult(message, Lease.Empty); return true; } - var items = result.GetItems(); - long length = 0; - foreach (ref RawResult item in items) + + // First pass: count total elements across all nested arrays + long totalLength = 0; + var iter = reader.AggregateChildren(); + while (iter.MoveNext()) { - if (item.Resp2TypeArray == ResultType.Array && !item.IsNull) + if (iter.Value.IsAggregate && !iter.Value.IsNull) { - length += GetArrayLength(in item); + totalLength += GetArrayLength(in iter.Value); } } - if (length == 0) + if (totalLength == 0) { SetResult(message, Lease.Empty); return true; } - var lease = Lease.Create(checked((int)length), clear: false); + + // Second pass: fill the lease + var lease = Lease.Create(checked((int)totalLength), clear: false); int index = 0; var target = lease.Span; - foreach (ref RawResult item in items) + + try { - if (item.Resp2TypeArray == ResultType.Array && !item.IsNull) + iter = reader.AggregateChildren(); + while (iter.MoveNext()) { - var iter = item.GetItems().GetEnumerator(); - while (index < target.Length && TryReadOne(ref iter, out target[index])) + if (iter.Value.IsAggregate && !iter.Value.IsNull) { - index++; + var childReader = iter.Value; + while (childReader.TryMoveNext() && index < target.Length) + { + if (!TryReadOne(ref childReader, out target[index])) + { + lease.Dispose(); + return false; + } + index++; + } } } - } - if (index == length) + if (index == totalLength) + { + SetResult(message, lease); + return true; + } + lease.Dispose(); // failed to fill? + return false; + } + catch { - SetResult(message, lease); - return true; + lease.Dispose(); + throw; } - lease.Dispose(); // failed to fill? - return false; } } private sealed class LeaseFloat32Processor : LeaseProcessor { - protected override bool TryParse(in RawResult raw, out float parsed) - { - var result = raw.TryGetDouble(out double val); - parsed = (float)val; - return result; - } + protected override float TryParse(ref RespReader reader) => (float)reader.ReadDouble(); } private sealed class LeaseProcessor : ResultProcessor> { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + if (reader.IsScalar) { - case ResultType.Integer: - case ResultType.SimpleString: - case ResultType.BulkString: - SetResult(message, result.AsLease()!); - return true; + SetResult(message, reader.AsLease()!); + return true; } return false; } @@ -199,18 +161,14 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class LeaseFromArrayProcessor : ResultProcessor> { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + if (reader.IsAggregate && reader.AggregateLengthIs(1) + && reader.TryMoveNext() && reader.IsScalar) { - case ResultType.Array: - var items = result.GetItems(); - if (items.Length == 1) - { // treat an array of 1 like a single reply - SetResult(message, items[0].AsLease()!); - return true; - } - break; + // treat an array of 1 like a single reply + SetResult(message, reader.AsLease()!); + return true; } return false; } diff --git a/src/StackExchange.Redis/ResultProcessor.Literals.cs b/src/StackExchange.Redis/ResultProcessor.Literals.cs new file mode 100644 index 000000000..7d50cc09e --- /dev/null +++ b/src/StackExchange.Redis/ResultProcessor.Literals.cs @@ -0,0 +1,48 @@ +using RESPite; + +namespace StackExchange.Redis; + +internal partial class ResultProcessor +{ + internal partial class Literals + { +#pragma warning disable CS8981, SA1300, SA1134 // forgive naming etc + // ReSharper disable InconsistentNaming + [AsciiHash] internal static partial class NOAUTH { } + [AsciiHash] internal static partial class WRONGPASS { } + [AsciiHash] internal static partial class NOSCRIPT { } + [AsciiHash] internal static partial class MOVED { } + [AsciiHash] internal static partial class ASK { } + [AsciiHash] internal static partial class READONLY { } + [AsciiHash] internal static partial class LOADING { } + [AsciiHash("ERR operation not permitted")] + internal static partial class ERR_not_permitted { } + + // Result processor literals + [AsciiHash] + internal static partial class OK + { + public static readonly AsciiHash Hash = new(U8); + } + + [AsciiHash] + internal static partial class PONG + { + public static readonly AsciiHash Hash = new(U8); + } + + [AsciiHash("Background saving started")] + internal static partial class background_saving_started + { + public static readonly AsciiHash Hash = new(U8); + } + + [AsciiHash("Background append only file rewriting started")] + internal static partial class background_aof_rewriting_started + { + public static readonly AsciiHash Hash = new(U8); + } + // ReSharper restore InconsistentNaming +#pragma warning restore CS8981, SA1300, SA1134 // forgive naming etc + } +} diff --git a/src/StackExchange.Redis/ResultProcessor.VectorSets.cs b/src/StackExchange.Redis/ResultProcessor.VectorSets.cs index f8f3bed72..df00b2091 100644 --- a/src/StackExchange.Redis/ResultProcessor.VectorSets.cs +++ b/src/StackExchange.Redis/ResultProcessor.VectorSets.cs @@ -1,6 +1,9 @@ -using Pipelines.Sockets.Unofficial.Arenas; +// ReSharper disable once CheckNamespace + +using System; +using RESPite; +using RESPite.Messages; -// ReSharper disable once CheckNamespace namespace StackExchange.Redis; internal abstract partial class ResultProcessor @@ -17,104 +20,128 @@ internal abstract partial class ResultProcessor private sealed class VectorSetLinksWithScoresProcessor : FlattenedLeaseProcessor { - protected override long GetArrayLength(in RawResult array) => array.GetItems().Length / 2; + protected override long GetArrayLength(in RespReader reader) => reader.AggregateLength() / 2; - protected override bool TryReadOne(ref Sequence.Enumerator reader, out VectorSetLink value) + protected override bool TryReadOne(ref RespReader reader, out VectorSetLink value) { - if (reader.MoveNext()) + if (!reader.IsScalar) { - ref readonly RawResult first = ref reader.Current; - if (reader.MoveNext() && reader.Current.TryGetDouble(out var score)) - { - value = new VectorSetLink(first.AsRedisValue(), score); - return true; - } + value = default; + return false; + } + + var member = reader.ReadRedisValue(); + if (!reader.TryMoveNext() || !reader.IsScalar || !reader.TryReadDouble(out var score)) + { + value = default; + return false; } - value = default; - return false; + value = new VectorSetLink(member, score); + return true; } } private sealed class VectorSetLinksProcessor : FlattenedLeaseProcessor { - protected override bool TryReadOne(in RawResult result, out RedisValue value) + protected override bool TryReadOne(ref RespReader reader, out RedisValue value) { - value = result.AsRedisValue(); + if (!reader.IsScalar) + { + value = default; + return false; + } + + value = reader.ReadRedisValue(); return true; } } private sealed class LeaseRedisValueProcessor : LeaseProcessor { - protected override bool TryParse(in RawResult raw, out RedisValue parsed) - { - parsed = raw.AsRedisValue(); - return true; - } + protected override RedisValue TryParse(ref RespReader reader) => reader.ReadRedisValue(); } private sealed partial class VectorSetInfoProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.Resp2TypeArray == ResultType.Array) + if (!reader.IsAggregate) return false; + + if (reader.IsNull) { - if (result.IsNull) - { - SetResult(message, null); - return true; - } + SetResult(message, null); + return true; + } + + var quantType = VectorSetQuantization.Unknown; + string? quantTypeRaw = null; + int vectorDim = 0, maxLevel = 0; + long resultSize = 0, vsetUid = 0, hnswMaxNodeUid = 0; + + // Iterate through key-value pairs + while (reader.TryMoveNext()) + { + // Read key + if (!reader.IsScalar) break; - var quantType = VectorSetQuantization.Unknown; - string? quantTypeRaw = null; - int vectorDim = 0, maxLevel = 0; - long resultSize = 0, vsetUid = 0, hnswMaxNodeUid = 0; - var iter = result.GetItems().GetEnumerator(); - while (iter.MoveNext()) + VectorSetInfoField field; + unsafe { - if (!iter.Current.TryParse(VectorSetInfoFieldMetadata.TryParse, out VectorSetInfoField field)) + if (!reader.TryParseScalar(&VectorSetInfoFieldMetadata.TryParse, out field)) + { field = VectorSetInfoField.Unknown; + } + } - if (!iter.MoveNext()) break; - ref readonly RawResult value = ref iter.Current; + // Move to value + if (!reader.TryMoveNext()) break; + // Skip non-scalar values (future-proofing) + if (!reader.IsScalar) + { + reader.SkipChildren(); + continue; + } + + unsafe + { switch (field) { - case VectorSetInfoField.Size when value.TryGetInt64(out var i64): + case VectorSetInfoField.Size when reader.TryReadInt64(out var i64): resultSize = i64; break; - case VectorSetInfoField.VsetUid when value.TryGetInt64(out var i64): + case VectorSetInfoField.VsetUid when reader.TryReadInt64(out var i64): vsetUid = i64; break; - case VectorSetInfoField.MaxLevel when value.TryGetInt64(out var i64): + case VectorSetInfoField.MaxLevel when reader.TryReadInt64(out var i64): maxLevel = checked((int)i64); break; - case VectorSetInfoField.VectorDim when value.TryGetInt64(out var i64): + case VectorSetInfoField.VectorDim when reader.TryReadInt64(out var i64): vectorDim = checked((int)i64); break; case VectorSetInfoField.QuantType - when value.TryParse(VectorSetQuantizationMetadata.TryParse, out VectorSetQuantization quantTypeValue) - && quantTypeValue is not VectorSetQuantization.Unknown: + when reader.TryParseScalar( + &VectorSetQuantizationMetadata.TryParse, + out VectorSetQuantization quantTypeValue) + && quantTypeValue is not VectorSetQuantization.Unknown: quantType = quantTypeValue; break; case VectorSetInfoField.QuantType: - quantTypeRaw = value.GetString(); + quantTypeRaw = reader.ReadString(); quantType = VectorSetQuantization.Unknown; break; - case VectorSetInfoField.HnswMaxNodeUid when value.TryGetInt64(out var i64): + case VectorSetInfoField.HnswMaxNodeUid when reader.TryReadInt64(out var i64): hnswMaxNodeUid = i64; break; } } - - SetResult( - message, - new VectorSetInfo(quantType, quantTypeRaw, vectorDim, resultSize, maxLevel, vsetUid, hnswMaxNodeUid)); - return true; } - return false; + SetResult( + message, + new VectorSetInfo(quantType, quantTypeRaw, vectorDim, resultSize, maxLevel, vsetUid, hnswMaxNodeUid)); + return true; } } } diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index fc5c3d5b4..730f67dbc 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -2,7 +2,6 @@ using System.Buffers; using System.Collections.Generic; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Net; @@ -10,7 +9,8 @@ using System.Text; using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; -using Pipelines.Sockets.Unofficial.Arenas; +using RESPite; +using RESPite.Messages; namespace StackExchange.Redis { @@ -18,15 +18,15 @@ internal abstract partial class ResultProcessor { public static readonly ResultProcessor Boolean = new BooleanProcessor(), - DemandOK = new ExpectBasicStringProcessor(CommonReplies.OK), - DemandPONG = new ExpectBasicStringProcessor(CommonReplies.PONG), + DemandOK = new ExpectBasicStringProcessor(Literals.OK.Hash), + DemandPONG = new ExpectBasicStringProcessor(Literals.PONG.Hash), DemandZeroOrOne = new DemandZeroOrOneProcessor(), AutoConfigure = new AutoConfigureProcessor(), TrackSubscriptions = new TrackSubscriptionsProcessor(null), Tracer = new TracerProcessor(false), EstablishConnection = new TracerProcessor(true), - BackgroundSaveStarted = new ExpectBasicStringProcessor(CommonReplies.backgroundSavingStarted_trimmed, startsWith: true), - BackgroundSaveAOFStarted = new ExpectBasicStringProcessor(CommonReplies.backgroundSavingAOFStarted_trimmed, startsWith: true); + BackgroundSaveStarted = new ExpectBasicStringProcessor(Literals.background_saving_started.Hash, startsWith: true), + BackgroundSaveAOFStarted = new ExpectBasicStringProcessor(Literals.background_aof_rewriting_started.Hash, startsWith: true); public static readonly ResultProcessor ByteArray = new ByteArrayProcessor(); @@ -222,133 +222,153 @@ public static void SetException(Message? message, Exception ex) box?.SetException(ex); } // true if ready to be completed (i.e. false if re-issued to another server) - public virtual bool SetResult(PhysicalConnection connection, Message message, in RawResult result) + public virtual bool SetResult(PhysicalConnection connection, Message message, ref RespReader reader) { + reader.MovePastBof(); + connection.OnDetailLog($"(core result for {message.Command}, '{reader.GetOverview()}')"); var bridge = connection.BridgeCouldBeNull; if (message is LoggingMessage logging) { try { - logging.Log?.LogInformationResponse(bridge?.Name, message.CommandAndKey, result); + logging.Log?.LogInformationResponse(bridge?.Name, message.CommandAndKey, reader.GetOverview()); } - catch { } - } - if (result.IsError) - { - if (result.StartsWith(CommonReplies.NOAUTH)) - { - bridge?.Multiplexer.SetAuthSuspect(new RedisServerException("NOAUTH Returned - connection has not yet authenticated")); - } - else if (result.StartsWith(CommonReplies.WRONGPASS)) + catch (Exception ex) { - bridge?.Multiplexer.SetAuthSuspect(new RedisServerException(result.ToString())); + Debug.WriteLine(ex.Message); } + } + if (reader.IsError) + { + return HandleCommonError(message, reader, connection); + } - var server = bridge?.ServerEndPoint; - bool log = !message.IsInternalCall; - bool isMoved = result.StartsWith(CommonReplies.MOVED); - bool wasNoRedirect = (message.Flags & CommandFlags.NoRedirect) != 0; - string? err = string.Empty; - bool unableToConnectError = false; - if (isMoved || result.StartsWith(CommonReplies.ASK)) - { - message.SetResponseReceived(); + var copy = reader; + if (SetResultCore(connection, message, ref reader)) + { + bridge?.Multiplexer.Trace("Completed with success: " + copy.GetOverview() + " (" + GetType().Name + ")", ToString()); + } + else + { + UnexpectedResponse(message, in copy); + } + return true; + } - log = false; - string[] parts = result.GetString()!.Split(StringSplits.Space, 3); - if (Format.TryParseInt32(parts[1], out int hashSlot) - && Format.TryParseEndPoint(parts[2], out var endpoint)) + private bool HandleCommonError(Message message, RespReader reader, PhysicalConnection connection) + { + connection.OnDetailLog($"applying common error-handling: {reader.GetOverview()}"); + var bridge = connection.BridgeCouldBeNull; + if (reader.StartsWith(Literals.NOAUTH.U8)) + { + bridge?.Multiplexer.SetAuthSuspect(new RedisServerException("NOAUTH Returned - connection has not yet authenticated")); + } + else if (reader.StartsWith(Literals.WRONGPASS.U8)) + { + bridge?.Multiplexer.SetAuthSuspect(new RedisServerException(reader.GetOverview())); + } + + var server = bridge?.ServerEndPoint; + bool log = !message.IsInternalCall; + bool isMoved = reader.StartsWith(Literals.MOVED.U8); + bool wasNoRedirect = (message.Flags & CommandFlags.NoRedirect) != 0; + string? err = string.Empty; + bool unableToConnectError = false; + if (isMoved || reader.StartsWith(Literals.ASK.U8)) + { + connection.OnDetailLog($"redirect via {(isMoved ? "MOVED" : "ASK")} to '{reader.ReadString()}'"); + message.SetResponseReceived(); + + log = false; + string[] parts = reader.ReadString()!.Split(StringSplits.Space, 3); + if (Format.TryParseInt32(parts[1], out int hashSlot) + && Format.TryParseEndPoint(parts[2], out var endpoint)) + { + // Check if MOVED points to same endpoint + bool isSameEndpoint = Equals(server?.EndPoint, endpoint); + if (isSameEndpoint && isMoved) { - // Check if MOVED points to same endpoint - bool isSameEndpoint = Equals(server?.EndPoint, endpoint); - if (isSameEndpoint && isMoved) - { - // MOVED to same endpoint detected. - // This occurs when Redis/Valkey servers are behind DNS records, load balancers, or proxies. - // The MOVED error signals that the client should reconnect to allow the DNS/proxy/load balancer - // to route the connection to a different underlying server host, then retry the command. - // Mark the bridge to reconnect - reader loop will handle disconnection and reconnection. - bridge?.MarkNeedsReconnect(); - } - if (bridge is null) - { - // already toast - } - else if (bridge.Multiplexer.TryResend(hashSlot, message, endpoint, isMoved, isSameEndpoint)) + // MOVED to same endpoint detected. + // This occurs when Redis/Valkey servers are behind DNS records, load balancers, or proxies. + // The MOVED error signals that the client should reconnect to allow the DNS/proxy/load balancer + // to route the connection to a different underlying server host, then retry the command. + // Mark the bridge to reconnect - reader loop will handle disconnection and reconnection. + bridge?.MarkNeedsReconnect(); + } + if (bridge is null) + { + // already toast + connection.OnDetailLog($"no bridge for {message.Command}"); + } + else if (bridge.Multiplexer.TryResend(hashSlot, message, endpoint, isMoved, isSameEndpoint)) + { + connection.OnDetailLog($"re-issued to {Format.ToString(endpoint)}"); + bridge.Multiplexer.Trace(message.Command + " re-issued to " + endpoint, isMoved ? "MOVED" : "ASK"); + return false; + } + else + { + connection.OnDetailLog($"unable to re-issue {message.Command} to {Format.ToString(endpoint)}; treating as error"); + if (isMoved && wasNoRedirect) { - bridge.Multiplexer.Trace(message.Command + " re-issued to " + endpoint, isMoved ? "MOVED" : "ASK"); - return false; + if (bridge.Multiplexer.RawConfig.IncludeDetailInExceptions) + { + err = $"Key has MOVED to Endpoint {endpoint} and hashslot {hashSlot} but CommandFlags.NoRedirect was specified - redirect not followed for {message.CommandAndKey}. "; + } + else + { + err = "Key has MOVED but CommandFlags.NoRedirect was specified - redirect not followed. "; + } } else { - if (isMoved && wasNoRedirect) + unableToConnectError = true; + if (bridge.Multiplexer.RawConfig.IncludeDetailInExceptions) { - if (bridge.Multiplexer.RawConfig.IncludeDetailInExceptions) - { - err = $"Key has MOVED to Endpoint {endpoint} and hashslot {hashSlot} but CommandFlags.NoRedirect was specified - redirect not followed for {message.CommandAndKey}. "; - } - else - { - err = "Key has MOVED but CommandFlags.NoRedirect was specified - redirect not followed. "; - } + err = $"Endpoint {endpoint} serving hashslot {hashSlot} is not reachable at this point of time. Please check connectTimeout value. If it is low, try increasing it to give the ConnectionMultiplexer a chance to recover from the network disconnect. " + + PerfCounterHelper.GetThreadPoolAndCPUSummary(); } else { - unableToConnectError = true; - if (bridge.Multiplexer.RawConfig.IncludeDetailInExceptions) - { - err = $"Endpoint {endpoint} serving hashslot {hashSlot} is not reachable at this point of time. Please check connectTimeout value. If it is low, try increasing it to give the ConnectionMultiplexer a chance to recover from the network disconnect. " - + PerfCounterHelper.GetThreadPoolAndCPUSummary(); - } - else - { - err = "Endpoint is not reachable at this point of time. Please check connectTimeout value. If it is low, try increasing it to give the ConnectionMultiplexer a chance to recover from the network disconnect. "; - } + err = "Endpoint is not reachable at this point of time. Please check connectTimeout value. If it is low, try increasing it to give the ConnectionMultiplexer a chance to recover from the network disconnect. "; } } } } - - if (string.IsNullOrWhiteSpace(err)) - { - err = result.GetString()!; - } - - if (log && server != null) - { - bridge?.Multiplexer.OnErrorMessage(server.EndPoint, err); - } - bridge?.Multiplexer.Trace("Completed with error: " + err + " (" + GetType().Name + ")", ToString()); - if (unableToConnectError) - { - ConnectionFail(message, ConnectionFailureType.UnableToConnect, err); - } else { - ServerFail(message, err); + connection.OnDetailLog($"unable to parse redirect response"); } } + + if (string.IsNullOrWhiteSpace(err)) + { + err = reader.ReadString()!; + } + + if (log && server != null) + { + bridge?.Multiplexer.OnErrorMessage(server.EndPoint, err); + } + bridge?.Multiplexer.Trace("Completed with error: " + err + " (" + GetType().Name + ")", ToString()); + if (unableToConnectError) + { + ConnectionFail(message, ConnectionFailureType.UnableToConnect, err); + } else { - bool coreResult = SetResultCore(connection, message, result); - if (coreResult) - { - bridge?.Multiplexer.Trace("Completed with success: " + result.ToString() + " (" + GetType().Name + ")", ToString()); - } - else - { - UnexpectedResponse(message, result); - } + ServerFail(message, err); } + return true; } - protected abstract bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result); + protected abstract bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader); - private void UnexpectedResponse(Message message, in RawResult result) + private void UnexpectedResponse(Message message, in RespReader reader) { ConnectionMultiplexer.TraceWithoutContext("From " + GetType().Name, "Unexpected Response"); - ConnectionFail(message, ConnectionFailureType.ProtocolFailure, "Unexpected response to " + (message?.CommandString ?? "n/a") + ": " + result.ToString()); + ConnectionFail(message, ConnectionFailureType.ProtocolFailure, "Unexpected response to " + (message?.CommandString ?? "n/a") + ": " + reader.GetOverview()); } public sealed class TimeSpanProcessor : ResultProcessor @@ -359,43 +379,34 @@ public TimeSpanProcessor(bool isMilliseconds) this.isMilliseconds = isMilliseconds; } - public bool TryParse(in RawResult result, out TimeSpan? expiry) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + if (reader.IsScalar) { - case ResultType.Integer: - if (result.TryGetInt64(out long time)) + if (reader.IsNull) + { + SetResult(message, null); + return true; + } + + if (reader.TryReadInt64(out long time)) + { + TimeSpan? expiry; + if (time < 0) { - if (time < 0) - { - expiry = null; - } - else if (isMilliseconds) - { - expiry = TimeSpan.FromMilliseconds(time); - } - else - { - expiry = TimeSpan.FromSeconds(time); - } - return true; + expiry = null; } - break; - // e.g. OBJECT IDLETIME on a key that doesn't exist - case ResultType.BulkString when result.IsNull: - expiry = null; + else if (isMilliseconds) + { + expiry = TimeSpan.FromMilliseconds(time); + } + else + { + expiry = TimeSpan.FromSeconds(time); + } + SetResult(message, expiry); return true; - } - expiry = null; - return false; - } - - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) - { - if (TryParse(result, out TimeSpan? expiry)) - { - SetResult(message, expiry); - return true; + } } return false; } @@ -408,30 +419,23 @@ public sealed class TimingProcessor : ResultProcessor public static TimerMessage CreateMessage(int db, CommandFlags flags, RedisCommand command, RedisValue value = default) => new TimerMessage(db, flags, command, value); - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.IsError) + // don't check the actual reply; there are multiple ways of constructing + // a timing message, and we don't actually care about what approach was used + TimeSpan duration; + if (message is TimerMessage timingMessage) { - return false; + var timestampDelta = Stopwatch.GetTimestamp() - timingMessage.StartedWritingTimestamp; + var ticks = (long)(TimestampToTicks * timestampDelta); + duration = new TimeSpan(ticks); } else { - // don't check the actual reply; there are multiple ways of constructing - // a timing message, and we don't actually care about what approach was used - TimeSpan duration; - if (message is TimerMessage timingMessage) - { - var timestampDelta = Stopwatch.GetTimestamp() - timingMessage.StartedWritingTimestamp; - var ticks = (long)(TimestampToTicks * timestampDelta); - duration = new TimeSpan(ticks); - } - else - { - duration = TimeSpan.MaxValue; - } - SetResult(message, duration); - return true; + duration = TimeSpan.MaxValue; } + SetResult(message, duration); + return true; } internal sealed class TimerMessage : Message @@ -444,17 +448,17 @@ public TimerMessage(int db, CommandFlags flags, RedisCommand command, RedisValue this.value = value; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { StartedWritingTimestamp = Stopwatch.GetTimestamp(); if (value.IsNull) { - physical.WriteHeader(command, 0); + writer.WriteHeader(command, 0); } else { - physical.WriteHeader(command, 1); - physical.WriteBulkString(value); + writer.WriteHeader(command, 1); + writer.WriteBulkString(value); } } public override int ArgCount => value.IsNull ? 0 : 1; @@ -466,32 +470,41 @@ public sealed class TrackSubscriptionsProcessor : ResultProcessor private ConnectionMultiplexer.Subscription? Subscription { get; } public TrackSubscriptionsProcessor(ConnectionMultiplexer.Subscription? sub) => Subscription = sub; - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.Resp2TypeArray == ResultType.Array) + if (reader.IsAggregate) { - var items = result.GetItems(); - if (items.Length >= 3 && items[2].TryGetInt64(out long count)) + int length = reader.AggregateLength(); + if (length >= 3) { - connection.SubscriptionCount = count; - SetResult(message, true); + var iter = reader.AggregateChildren(); + // Skip first two elements + iter.DemandNext(); // [0] + iter.DemandNext(); // [1] + iter.DemandNext(); // [2] - the count - var ep = connection.BridgeCouldBeNull?.ServerEndPoint; - if (ep is not null) + if (iter.Value.TryReadInt64(out long count)) { - switch (message.Command) + connection.SubscriptionCount = count; + SetResult(message, true); + + var ep = connection.BridgeCouldBeNull?.ServerEndPoint; + if (ep is not null) { - case RedisCommand.SUBSCRIBE: - case RedisCommand.SSUBSCRIBE: - case RedisCommand.PSUBSCRIBE: - Subscription?.AddEndpoint(ep); - break; - default: - Subscription?.TryRemoveEndpoint(ep); - break; + switch (message.Command) + { + case RedisCommand.SUBSCRIBE: + case RedisCommand.SSUBSCRIBE: + case RedisCommand.PSUBSCRIBE: + Subscription?.AddEndpoint(ep); + break; + default: + Subscription?.TryRemoveEndpoint(ep); + break; + } } + return true; } - return true; } } SetResult(message, false); @@ -501,32 +514,30 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes internal sealed class DemandZeroOrOneProcessor : ResultProcessor { - public static bool TryGet(in RawResult result, out bool value) + public static bool TryGet(ref RespReader reader, out bool value) { - switch (result.Resp2TypeBulkString) + if (reader.IsScalar && reader.ScalarLengthIs(1)) { - case ResultType.Integer: - case ResultType.SimpleString: - case ResultType.BulkString: - if (result.IsEqual(CommonReplies.one)) - { - value = true; - return true; - } - else if (result.IsEqual(CommonReplies.zero)) - { - value = false; - return true; - } - break; + var span = reader.TryGetSpan(out var tmp) ? tmp : reader.Buffer(stackalloc byte[8]); + var byteValue = span[0]; + if (byteValue == (byte)'1') + { + value = true; + return true; + } + if (byteValue == (byte)'0') + { + value = false; + return true; + } } value = false; return false; } - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (TryGet(result, out bool value)) + if (TryGet(ref reader, out bool value)) { SetResult(message, value); return true; @@ -547,7 +558,7 @@ internal sealed class ScriptLoadProcessor : ResultProcessor internal static bool IsSHA1(string? script) => script is not null && script.Length == SHA1Length && sha1.IsMatch(script); internal const int Sha1HashLength = 20; - internal static byte[] ParseSHA1(byte[] value) + internal static byte[] ParseSHA1(ReadOnlySpan value) { static int FromHex(char c) { @@ -557,7 +568,7 @@ static int FromHex(char c) return -1; } - if (value?.Length == Sha1HashLength * 2) + if (value.Length == Sha1HashLength * 2) { var tmp = new byte[Sha1HashLength]; int charIndex = 0; @@ -577,24 +588,31 @@ static int FromHex(char c) // note that top-level error messages still get handled by SetResult, but nested errors // (is that a thing?) will be wrapped in the RedisResult - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + // We expect a scalar with exactly 40 ASCII hex characters (20 bytes when parsed) + if (reader.IsScalar && reader.ScalarLengthIs(Sha1HashLength * 2)) { - case ResultType.BulkString: - var asciiHash = result.GetBlob(); - if (asciiHash == null || asciiHash.Length != (Sha1HashLength * 2)) return false; + var asciiHash = reader.ReadByteArray()!; - // External caller wants the hex bytes, not the ASCII bytes - // For nullability/consistency reasons, we always do the parse here. - byte[] hash = ParseSHA1(asciiHash); + // External caller wants the hex bytes, not the ASCII bytes + // For nullability/consistency reasons, we always do the parse here. + byte[] hash; + try + { + hash = ParseSHA1(asciiHash); + } + catch (ArgumentException) + { + return false; // Invalid hex characters + } - if (message is RedisDatabase.ScriptLoadMessage sl) - { - connection.BridgeCouldBeNull?.ServerEndPoint?.AddScript(sl.Script, asciiHash); - } - SetResult(message, hash); - return true; + if (message is RedisDatabase.ScriptLoadMessage sl) + { + connection.BridgeCouldBeNull?.ServerEndPoint?.AddScript(sl.Script, asciiHash); + } + SetResult(message, hash); + return true; } return false; } @@ -602,59 +620,80 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes internal sealed class SortedSetEntryProcessor : ResultProcessor { - public static bool TryParse(in RawResult result, out SortedSetEntry? entry) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeArray) + // Handle array with at least 2 elements: [element, score, ...], or null/empty array + if (reader.IsAggregate) { - case ResultType.Array: - if (result.IsNull || result.ItemsCount < 2) - { - entry = null; - } - else + SortedSetEntry? result = null; + + // Note: null arrays report false for TryMoveNext, so no explicit null check needed + if (reader.TryMoveNext() && reader.IsScalar) + { + var element = reader.ReadRedisValue(); + if (reader.TryMoveNext() && reader.IsScalar) { - var arr = result.GetItems(); - entry = new SortedSetEntry(arr[0].AsRedisValue(), arr[1].TryGetDouble(out double val) ? val : double.NaN); + var score = reader.TryReadDouble(out var val) ? val : double.NaN; + result = new SortedSetEntry(element, score); } - return true; - default: - entry = null; - return false; - } - } + } - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) - { - if (TryParse(result, out SortedSetEntry? entry)) - { - SetResult(message, entry); + SetResult(message, result); return true; } + return false; } } internal sealed class SortedSetEntryArrayProcessor : ValuePairInterleavedProcessorBase { - protected override SortedSetEntry Parse(in RawResult first, in RawResult second, object? state) => - new SortedSetEntry(first.AsRedisValue(), second.TryGetDouble(out double val) ? val : double.NaN); + protected override SortedSetEntry Parse(ref RespReader first, ref RespReader second, object? state) => + new SortedSetEntry(first.ReadRedisValue(), second.TryReadDouble(out double val) ? val : double.NaN); } internal sealed class SortedSetPopResultProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.Resp2TypeArray == ResultType.Array) + // Handle array of 2: [key, array of SortedSetEntry] or null aggregate + if (reader.IsAggregate) { - if (result.IsNull) + // Handle null (RESP3 pure null or RESP2 null array) + if (reader.IsNull) { SetResult(message, Redis.SortedSetPopResult.Null); return true; } - var arr = result.GetItems(); - SetResult(message, new SortedSetPopResult(arr[0].AsRedisKey(), arr[1].GetItemsAsSortedSetEntryArray()!)); - return true; + if (reader.TryMoveNext() && reader.IsScalar) + { + var key = reader.ReadRedisKey(); + + // Read the second element (array of SortedSetEntry) + if (reader.TryMoveNext() && reader.IsAggregate) + { + var entries = reader.ReadPastArray( + static (ref r) => + { + // Each entry is an array of 2: [element, score] + if (r.IsAggregate && r.TryMoveNext() && r.IsScalar) + { + var element = r.ReadRedisValue(); + if (r.TryMoveNext() && r.IsScalar) + { + var score = r.TryReadDouble(out var val) ? val : double.NaN; + return new SortedSetEntry(element, score); + } + } + return default; + }, + scalar: false); + + SetResult(message, new SortedSetPopResult(key, entries!)); + return true; + } + } } return false; @@ -663,19 +702,30 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes internal sealed class ListPopResultProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.Resp2TypeArray == ResultType.Array) + // Handle array of 2: [key, array of values] or null aggregate + if (reader.IsAggregate) { - if (result.IsNull) + // Handle null (RESP3 pure null or RESP2 null array) + if (reader.IsNull) { SetResult(message, Redis.ListPopResult.Null); return true; } - var arr = result.GetItems(); - SetResult(message, new ListPopResult(arr[0].AsRedisKey(), arr[1].GetItemsAsValues()!)); - return true; + if (reader.TryMoveNext() && reader.IsScalar) + { + var key = reader.ReadRedisKey(); + + // Read the second element (array of RedisValue) + if (reader.TryMoveNext() && reader.IsAggregate) + { + var values = reader.ReadPastRedisValues(); + SetResult(message, new ListPopResult(key, values!)); + return true; + } + } } return false; @@ -684,8 +734,8 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes internal sealed class HashEntryArrayProcessor : ValuePairInterleavedProcessorBase { - protected override HashEntry Parse(in RawResult first, in RawResult second, object? state) => - new HashEntry(first.AsRedisValue(), second.AsRedisValue()); + protected override HashEntry Parse(ref RespReader first, ref RespReader second, object? state) => + new HashEntry(first.ReadRedisValue(), second.ReadRedisValue()); } internal abstract class ValuePairInterleavedProcessorBase : ResultProcessor @@ -697,121 +747,95 @@ internal abstract class ValuePairInterleavedProcessorBase : ResultProcessor true; - public bool TryParse(in RawResult result, out T[]? pairs) - => TryParse(result, out pairs, false, out _); + private static bool IsAllJaggedPairsReader(in RespReader reader) + { + // Check whether each child element is an array of exactly length 2 + // Use AggregateChildren to create isolated child iterators without mutating the reader + var iter = reader.AggregateChildren(); + while (iter.MoveNext()) + { + // Check if this child is an array with exactly 2 elements + if (!(iter.Value.IsAggregate && iter.Value.AggregateLengthIs(2))) + { + return false; + } + } + return true; + } - public T[]? ParseArray(in RawResult result, bool allowOversized, out int count, object? state) + public T[]? ParseArray(ref RespReader reader, RedisProtocol protocol, bool allowOversized, out int count, object? state) { - if (result.IsNull) + if (reader.IsNull) { count = 0; return null; } - var arr = result.GetItems(); - count = (int)arr.Length; + // Get the aggregate length first + count = reader.AggregateLength(); if (count == 0) { return []; } - bool interleaved = !(result.IsResp3 && AllowJaggedPairs && IsAllJaggedPairs(arr)); - if (interleaved) count >>= 1; // so: half of that - var pairs = allowOversized ? ArrayPool.Shared.Rent(count) : new T[count]; + // Check if we have jagged pairs (RESP3 style) or interleaved (RESP2 style) + bool isJagged = protocol == RedisProtocol.Resp3 && AllowJaggedPairs && IsAllJaggedPairsReader(reader); - if (interleaved) + if (isJagged) { - // linear elements i.e. {key,value,key,value,key,value} - if (arr.IsSingleSegment) - { - var span = arr.FirstSpan; - int offset = 0; - for (int i = 0; i < count; i++) - { - pairs[i] = Parse(span[offset++], span[offset++], state); - } - } - else + // Jagged format: [[k1, v1], [k2, v2], ...] + // Count is the number of pairs (outer array length) + var pairs = allowOversized ? ArrayPool.Shared.Rent(count) : new T[count]; + var iter = reader.AggregateChildren(); + for (int i = 0; i < count; i++) { - var iter = arr.GetEnumerator(); // simplest way of getting successive values - for (int i = 0; i < count; i++) - { - pairs[i] = Parse(iter.GetNext(), iter.GetNext(), state); - } + iter.DemandNext(); + + var pairIter = iter.Value.AggregateChildren(); + pairIter.DemandNext(); + var first = pairIter.Value; + + pairIter.DemandNext(); + var second = pairIter.Value; + + pairs[i] = Parse(ref first, ref second, state); } + return pairs; } else { - // jagged elements i.e. {{key,value},{key,value},{key,value}} - // to get here, we've already asserted that all elements are arrays with length 2 - if (arr.IsSingleSegment) - { - int i = 0; - foreach (var el in arr.FirstSpan) - { - var inner = el.GetItems(); - pairs[i++] = Parse(inner[0], inner[1], state); - } - } - else + // Interleaved format: [k1, v1, k2, v2, ...] + // Count is half the array length (>> 1 discards odd element if present) + count >>= 1; // divide by 2 + var pairs = allowOversized ? ArrayPool.Shared.Rent(count) : new T[count]; + var iter = reader.AggregateChildren(); + + for (int i = 0; i < count; i++) { - var iter = arr.GetEnumerator(); // simplest way of getting successive values - for (int i = 0; i < count; i++) - { - var inner = iter.GetNext().GetItems(); - pairs[i] = Parse(inner[0], inner[1], state); - } - } - } - return pairs; + iter.DemandNext(); + var first = iter.Value; - static bool IsAllJaggedPairs(in Sequence arr) - { - return arr.IsSingleSegment ? CheckSpan(arr.FirstSpan) : CheckSpans(arr); + iter.DemandNext(); + var second = iter.Value; - static bool CheckSpans(in Sequence arr) - { - foreach (var chunk in arr.Spans) - { - if (!CheckSpan(chunk)) return false; - } - return true; - } - static bool CheckSpan(ReadOnlySpan chunk) - { - // check whether each value is actually an array of length 2 - foreach (ref readonly RawResult el in chunk) - { - if (el is not { Resp2TypeArray: ResultType.Array, ItemsCount: 2 }) return false; - } - return true; + pairs[i] = Parse(ref first, ref second, state); } + return pairs; } } - public bool TryParse(in RawResult result, out T[]? pairs, bool allowOversized, out int count) - { - switch (result.Resp2TypeArray) - { - case ResultType.Array: - pairs = ParseArray(in result, allowOversized, out count, null); - return true; - default: - count = 0; - pairs = null; - return false; - } - } + protected abstract T Parse(ref RespReader first, ref RespReader second, object? state); - protected abstract T Parse(in RawResult first, in RawResult second, object? state); - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (TryParse(result, out T[]? arr)) + if (!reader.IsAggregate) { - SetResult(message, arr!); - return true; + return false; } - return false; + + var pairs = ParseArray(ref reader, connection.Protocol.GetValueOrDefault(), false, out _, null); + SetResult(message, pairs!); + return true; } } @@ -820,9 +844,11 @@ internal sealed class AutoConfigureProcessor : ResultProcessor private ILogger? Log { get; } public AutoConfigureProcessor(ILogger? log = null) => Log = log; - public override bool SetResult(PhysicalConnection connection, Message message, in RawResult result) + public override bool SetResult(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.IsError && result.StartsWith(CommonReplies.READONLY)) + var copy = reader; + reader.MovePastBof(); + if (reader.IsError && reader.StartsWith(Literals.READONLY.U8)) { var bridge = connection.BridgeCouldBeNull; if (bridge != null) @@ -833,199 +859,232 @@ public override bool SetResult(PhysicalConnection connection, Message message, i } } - return base.SetResult(connection, message, result); + return base.SetResult(connection, message, ref copy); } - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { var server = connection.BridgeCouldBeNull?.ServerEndPoint; if (server == null) return false; - switch (result.Resp2TypeBulkString) + // Handle CLIENT command (returns integer client ID) + if (message?.Command == RedisCommand.CLIENT && reader.Prefix == RespPrefix.Integer) { - case ResultType.Integer: - if (message?.Command == RedisCommand.CLIENT) + if (reader.TryReadInt64(out long clientId)) + { + connection.ConnectionId = clientId; + Log?.LogInformationAutoConfiguredClientConnectionId(new(server), clientId); + SetResult(message, true); + return true; + } + return false; + } + + // Handle INFO command (returns bulk string) + if (message?.Command == RedisCommand.INFO && reader.IsScalar) + { + string? info = reader.ReadString(); + if (string.IsNullOrWhiteSpace(info)) + { + SetResult(message, true); + return true; + } + string? primaryHost = null, primaryPort = null; + bool roleSeen = false; + using (var stringReader = new StringReader(info)) + { + while (stringReader.ReadLine() is string line) { - if (result.TryGetInt64(out long clientId)) + if (string.IsNullOrWhiteSpace(line) || line.StartsWith("# ")) { - connection.ConnectionId = clientId; - Log?.LogInformationAutoConfiguredClientConnectionId(new(server), clientId); + continue; + } - SetResult(message, true); - return true; + string? val; + if ((val = Extract(line, "role:")) != null) + { + roleSeen = true; + if (TryParseRole(val, out bool isReplica)) + { + server.IsReplica = isReplica; + Log?.LogInformationAutoConfiguredInfoRole(new(server), isReplica ? "replica" : "primary"); + } } - } - break; - case ResultType.BulkString: - if (message?.Command == RedisCommand.INFO) - { - string? info = result.GetString(); - if (string.IsNullOrWhiteSpace(info)) + else if ((val = Extract(line, "master_host:")) != null) { - SetResult(message, true); - return true; + primaryHost = val; } - string? primaryHost = null, primaryPort = null; - bool roleSeen = false; - using (var reader = new StringReader(info)) + else if ((val = Extract(line, "master_port:")) != null) { - while (reader.ReadLine() is string line) + primaryPort = val; + } + else if ((val = Extract(line, "redis_version:")) != null) + { + if (Format.TryParseVersion(val, out Version? version)) { - if (string.IsNullOrWhiteSpace(line) || line.StartsWith("# ")) - { - continue; - } - - string? val; - if ((val = Extract(line, "role:")) != null) - { - roleSeen = true; - if (TryParseRole(val, out bool isReplica)) - { - server.IsReplica = isReplica; - Log?.LogInformationAutoConfiguredInfoRole(new(server), isReplica ? "replica" : "primary"); - } - } - else if ((val = Extract(line, "master_host:")) != null) - { - primaryHost = val; - } - else if ((val = Extract(line, "master_port:")) != null) - { - primaryPort = val; - } - else if ((val = Extract(line, "redis_version:")) != null) - { - if (Format.TryParseVersion(val, out Version? version)) - { - server.Version = version; - Log?.LogInformationAutoConfiguredInfoVersion(new(server), version); - } - } - else if ((val = Extract(line, "redis_mode:")) != null) - { - if (TryParseServerType(val, out var serverType)) - { - server.ServerType = serverType; - Log?.LogInformationAutoConfiguredInfoServerType(new(server), serverType); - } - } - else if ((val = Extract(line, "run_id:")) != null) - { - server.RunId = val; - } + server.Version = version; + Log?.LogInformationAutoConfiguredInfoVersion(new(server), version); } - if (roleSeen && Format.TryParseEndPoint(primaryHost!, primaryPort, out var sep)) + } + else if ((val = Extract(line, "redis_mode:")) != null) + { + if (TryParseServerType(val, out var serverType)) { - // These are in the same section, if present - server.PrimaryEndPoint = sep; + server.ServerType = serverType; + Log?.LogInformationAutoConfiguredInfoServerType(new(server), serverType); } } + else if ((val = Extract(line, "run_id:")) != null) + { + server.RunId = val; + } } - else if (message?.Command == RedisCommand.SENTINEL) + if (roleSeen && Format.TryParseEndPoint(primaryHost!, primaryPort, out var sep)) { - server.ServerType = ServerType.Sentinel; - Log?.LogInformationAutoConfiguredSentinelServerType(new(server)); + // These are in the same section, if present + server.PrimaryEndPoint = sep; } - SetResult(message, true); - return true; - case ResultType.Array: - if (message?.Command == RedisCommand.CONFIG) + } + SetResult(message, true); + return true; + } + + // Handle SENTINEL command (returns bulk string) + if (message?.Command == RedisCommand.SENTINEL && reader.IsScalar) + { + server.ServerType = ServerType.Sentinel; + Log?.LogInformationAutoConfiguredSentinelServerType(new(server)); + SetResult(message, true); + return true; + } + + // Handle CONFIG command (returns array of key-value pairs) + if (message?.Command == RedisCommand.CONFIG && reader.IsAggregate) + { + // ReSharper disable once HeuristicUnreachableCode - this is a compile-time Max(...) + var iter = reader.AggregateChildren(); + while (iter.MoveNext()) + { + var key = iter.Value; + ConfigField field; + unsafe { - var iter = result.GetItems().GetEnumerator(); - while (iter.MoveNext()) + if (!key.TryParseScalar(&ConfigFieldMetadata.TryParse, out field)) { - ref RawResult key = ref iter.Current; - if (!iter.MoveNext()) break; - ref RawResult val = ref iter.Current; + field = ConfigField.Unknown; + } + } - if (key.IsEqual(CommonReplies.timeout) && val.TryGetInt64(out long i64)) + if (!iter.MoveNext()) break; + var val = iter.Value; + + switch (field) + { + case ConfigField.Timeout when val.TryReadInt64(out long i64): + // note the configuration is in seconds + int timeoutSeconds = checked((int)i64), targetSeconds; + if (timeoutSeconds > 0) { - // note the configuration is in seconds - int timeoutSeconds = checked((int)i64), targetSeconds; - if (timeoutSeconds > 0) + if (timeoutSeconds >= 60) { - if (timeoutSeconds >= 60) - { - targetSeconds = timeoutSeconds - 20; // time to spare... - } - else - { - targetSeconds = (timeoutSeconds * 3) / 4; - } - Log?.LogInformationAutoConfiguredConfigTimeout(new(server), targetSeconds); - server.WriteEverySeconds = targetSeconds; + targetSeconds = timeoutSeconds - 20; // time to spare... } - } - else if (key.IsEqual(CommonReplies.databases) && val.TryGetInt64(out i64)) - { - int dbCount = checked((int)i64); - Log?.LogInformationAutoConfiguredConfigDatabases(new(server), dbCount); - server.Databases = dbCount; - if (dbCount > 1) + else { - connection.MultiDatabasesOverride = true; + targetSeconds = (timeoutSeconds * 3) / 4; } + Log?.LogInformationAutoConfiguredConfigTimeout(new(server), targetSeconds); + server.WriteEverySeconds = targetSeconds; } - else if (key.IsEqual(CommonReplies.slave_read_only) || key.IsEqual(CommonReplies.replica_read_only)) + break; + case ConfigField.Databases when val.TryReadInt64(out long dbI64): + int dbCount = checked((int)dbI64); + Log?.LogInformationAutoConfiguredConfigDatabases(new(server), dbCount); + server.Databases = dbCount; + if (dbCount > 1) { - if (val.IsEqual(CommonReplies.yes)) - { - server.ReplicaReadOnly = true; - Log?.LogInformationAutoConfiguredConfigReadOnlyReplica(new(server), true); - } - else if (val.IsEqual(CommonReplies.no)) + connection.MultiDatabasesOverride = true; + } + break; + case ConfigField.SlaveReadOnly: + case ConfigField.ReplicaReadOnly: + YesNo yesNo; + unsafe + { + if (val.TryParseScalar(&YesNoMetadata.TryParse, out yesNo)) { - server.ReplicaReadOnly = false; - Log?.LogInformationAutoConfiguredConfigReadOnlyReplica(new(server), false); + switch (yesNo) + { + case YesNo.Yes: + server.ReplicaReadOnly = true; + Log?.LogInformationAutoConfiguredConfigReadOnlyReplica( + new(server), + true); + break; + case YesNo.No: + server.ReplicaReadOnly = false; + Log?.LogInformationAutoConfiguredConfigReadOnlyReplica( + new(server), + false); + break; + } } } - } + + break; } - else if (message?.Command == RedisCommand.HELLO) + } + SetResult(message, true); + return true; + } + + // Handle HELLO command (returns array/map of key-value pairs) + if (message?.Command == RedisCommand.HELLO && reader.IsAggregate) + { + var iter = reader.AggregateChildren(); + while (iter.MoveNext()) + { + var key = iter.Value; + HelloField field; + unsafe { - var iter = result.GetItems().GetEnumerator(); - while (iter.MoveNext()) + if (!key.TryParseScalar(&HelloFieldMetadata.TryParse, out field)) { - ref RawResult key = ref iter.Current; - if (!iter.MoveNext()) break; - ref RawResult val = ref iter.Current; - - if (key.IsEqual(CommonReplies.version) && Format.TryParseVersion(val.GetString(), out var version)) - { - server.Version = version; - Log?.LogInformationAutoConfiguredHelloServerVersion(new(server), version); - } - else if (key.IsEqual(CommonReplies.proto) && val.TryGetInt64(out var i64)) - { - connection.SetProtocol(i64 >= 3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2); - Log?.LogInformationAutoConfiguredHelloProtocol(new(server), connection.Protocol ?? RedisProtocol.Resp2); - } - else if (key.IsEqual(CommonReplies.id) && val.TryGetInt64(out i64)) - { - connection.ConnectionId = i64; - Log?.LogInformationAutoConfiguredHelloConnectionId(new(server), i64); - } - else if (key.IsEqual(CommonReplies.mode) && TryParseServerType(val.GetString(), out var serverType)) - { - server.ServerType = serverType; - Log?.LogInformationAutoConfiguredHelloServerType(new(server), serverType); - } - else if (key.IsEqual(CommonReplies.role) && TryParseRole(val.GetString(), out bool isReplica)) - { - server.IsReplica = isReplica; - Log?.LogInformationAutoConfiguredHelloRole(new(server), isReplica ? "replica" : "primary"); - } + field = HelloField.Unknown; } } - else if (message?.Command == RedisCommand.SENTINEL) + + if (!iter.MoveNext()) break; + var val = iter.Value; + + switch (field) { - server.ServerType = ServerType.Sentinel; - Log?.LogInformationAutoConfiguredSentinelServerType(new(server)); + case HelloField.Version when Format.TryParseVersion(val.ReadString(), out var version): + server.Version = version; + Log?.LogInformationAutoConfiguredHelloServerVersion(new(server), version); + break; + case HelloField.Proto when val.TryReadInt64(out var i64): + connection.SetProtocol(i64 >= 3 ? RedisProtocol.Resp3 : RedisProtocol.Resp2); + Log?.LogInformationAutoConfiguredHelloProtocol(new(server), connection.Protocol ?? RedisProtocol.Resp2); + break; + case HelloField.Id when val.TryReadInt64(out var i64): + connection.ConnectionId = i64; + Log?.LogInformationAutoConfiguredHelloConnectionId(new(server), i64); + break; + case HelloField.Mode when TryParseServerType(val.ReadString(), out var serverType): + server.ServerType = serverType; + Log?.LogInformationAutoConfiguredHelloServerType(new(server), serverType); + break; + case HelloField.Role when TryParseRole(val.ReadString(), out bool isReplica): + server.IsReplica = isReplica; + Log?.LogInformationAutoConfiguredHelloRole(new(server), isReplica ? "replica" : "primary"); + break; } - SetResult(message, true); - return true; + } + SetResult(message, true); + return true; } + return false; } @@ -1077,51 +1136,43 @@ private static bool TryParseRole(string? val, out bool isReplica) private sealed class BooleanProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.IsNull) + if (reader.IsNull) { SetResult(message, false); // lots of ops return (nil) when they mean "no" return true; } - switch (result.Resp2TypeBulkString) + + if (reader.IsScalar) { - case ResultType.SimpleString: - if (result.IsEqual(CommonReplies.OK)) - { - SetResult(message, true); - } - else - { - SetResult(message, result.GetBoolean()); - } - return true; - case ResultType.Integer: - case ResultType.BulkString: - SetResult(message, result.GetBoolean()); + SetResult(message, reader.ReadBoolean()); + return true; + } + + if (reader.IsAggregate && reader.TryMoveNext() && reader.IsScalar) + { + // treat an array of 1 like a single reply (for example, SCRIPT EXISTS) + var value = reader.ReadBoolean(); + if (!reader.TryMoveNext()) + { + SetResult(message, value); return true; - case ResultType.Array: - var items = result.GetItems(); - if (items.Length == 1) - { // treat an array of 1 like a single reply (for example, SCRIPT EXISTS) - SetResult(message, items[0].GetBoolean()); - return true; - } - break; + } } + return false; } } private sealed class ByteArrayProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + if (reader.IsScalar) { - case ResultType.BulkString: - SetResult(message, result.GetBlob()); - return true; + SetResult(message, reader.ReadByteArray()); + return true; } return false; } @@ -1138,12 +1189,13 @@ internal static ClusterConfiguration Parse(PhysicalConnection connection, string return config; } - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + if (reader.IsScalar) { - case ResultType.BulkString: - string nodes = result.GetString()!; + string? nodes = reader.ReadString(); + if (nodes is not null) + { var bridge = connection.BridgeCouldBeNull; var config = Parse(connection, nodes); @@ -1154,6 +1206,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes } SetResult(message, config); return true; + } } return false; } @@ -1161,24 +1214,24 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class ClusterNodesRawProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + if (reader.IsScalar) { - case ResultType.Integer: - case ResultType.SimpleString: - case ResultType.BulkString: - string nodes = result.GetString()!; - try - { - ClusterNodesProcessor.Parse(connection, nodes); - } - catch + var nodes = reader.ReadString(); + try + { + if (!string.IsNullOrWhiteSpace(nodes)) { - /* tralalalala */ + ClusterNodesProcessor.Parse(connection, nodes!); } - SetResult(message, nodes); - return true; + } + catch + { + /* tralalalala */ + } + SetResult(message, nodes); + return true; } return false; } @@ -1186,55 +1239,49 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class ConnectionIdentityProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (connection.BridgeCouldBeNull is PhysicalBridge bridge) - { - SetResult(message, bridge.ServerEndPoint.EndPoint); - return true; - } - return false; + SetResult(message, connection.BridgeCouldBeNull?.ServerEndPoint.EndPoint!); + return true; } } private sealed class DateTimeProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - long unixTime; - switch (result.Resp2TypeArray) + // Handle scalar integer (seconds since Unix epoch) + if (reader.IsScalar && reader.TryReadInt64(out long unixTime)) { - case ResultType.Integer: - if (result.TryGetInt64(out unixTime)) + var time = RedisBase.UnixEpoch.AddSeconds(unixTime); + SetResult(message, time); + return true; + } + + // Handle array (TIME command returns [seconds, microseconds]) + if (reader.IsAggregate && reader.TryMoveNext() && reader.IsScalar) + { + if (reader.TryReadInt64(out unixTime)) + { + // Check if there's a second element (microseconds) + if (!reader.TryMoveNext()) { + // Array of 1: just seconds var time = RedisBase.UnixEpoch.AddSeconds(unixTime); SetResult(message, time); return true; } - break; - case ResultType.Array: - var arr = result.GetItems(); - switch (arr.Length) + + // Array of 2: seconds + microseconds - verify no third element + if (reader.IsScalar && reader.TryReadInt64(out long micros) && !reader.TryMoveNext()) { - case 1: - if (arr.FirstSpan[0].TryGetInt64(out unixTime)) - { - var time = RedisBase.UnixEpoch.AddSeconds(unixTime); - SetResult(message, time); - return true; - } - break; - case 2: - if (arr[0].TryGetInt64(out unixTime) && arr[1].TryGetInt64(out long micros)) - { - var time = RedisBase.UnixEpoch.AddSeconds(unixTime).AddTicks(micros * 10); // DateTime ticks are 100ns - SetResult(message, time); - return true; - } - break; + var time = RedisBase.UnixEpoch.AddSeconds(unixTime).AddTicks(micros * 10); // DateTime ticks are 100ns + SetResult(message, time); + return true; } - break; + } } + return false; } } @@ -1244,11 +1291,20 @@ public sealed class NullableDateTimeProcessor : ResultProcessor private readonly bool isMilliseconds; public NullableDateTimeProcessor(bool fromMilliseconds) => isMilliseconds = fromMilliseconds; - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + if (reader.IsScalar) { - case ResultType.Integer when result.TryGetInt64(out var duration): + // Handle null (e.g., OBJECT IDLETIME on a key that doesn't exist) + if (reader.IsNull) + { + SetResult(message, null); + return true; + } + + // Handle integer (TTL/PTTL/EXPIRETIME commands) + if (reader.TryReadInt64(out var duration)) + { DateTime? expiry = duration switch { // -1 means no expiry and -2 means key does not exist @@ -1258,36 +1314,26 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes }; SetResult(message, expiry); return true; - - case ResultType.BulkString when result.IsNull: - SetResult(message, null); - return true; + } } + return false; } } private sealed class DoubleProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + if (reader.Prefix is RespPrefix.Integer && reader.TryReadInt64(out long i64)) { - case ResultType.Integer: - if (result.TryGetInt64(out long i64)) - { - SetResult(message, i64); - return true; - } - break; - case ResultType.SimpleString: - case ResultType.BulkString: - if (result.TryGetDouble(out double val)) - { - SetResult(message, val); - return true; - } - break; + SetResult(message, i64); + return true; + } + if (reader.IsScalar && reader.TryReadDouble(out double val)) + { + SetResult(message, val); + return true; } return false; } @@ -1295,21 +1341,38 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class ExpectBasicStringProcessor : ResultProcessor { - private readonly CommandBytes _expected; + private readonly AsciiHash _expected; private readonly bool _startsWith; - public ExpectBasicStringProcessor(CommandBytes expected, bool startsWith = false) + + public ExpectBasicStringProcessor(in AsciiHash expected, bool startsWith = false) { _expected = expected; _startsWith = startsWith; } - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (_startsWith ? result.StartsWith(_expected) : result.IsEqual(_expected)) + if (!reader.IsScalar) return false; + + var expectedLength = _expected.Length; + // For exact match, length must be exact + if (_startsWith) + { + if (reader.ScalarLength() < expectedLength) return false; + } + else + { + if (!reader.ScalarLengthIs(expectedLength)) return false; + } + + var bytes = reader.TryGetSpan(out var tmp) ? tmp : reader.Buffer(stackalloc byte[_expected.BufferLength]); + if (_startsWith) bytes = bytes.Slice(0, expectedLength); + if (_expected.IsCS(bytes)) { SetResult(message, true); return true; } + if (message.Command == RedisCommand.AUTH) connection?.BridgeCouldBeNull?.Multiplexer?.SetAuthSuspect(new RedisException("Unknown AUTH exception")); return false; } @@ -1317,17 +1380,16 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class InfoProcessor : ResultProcessor>[]> { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.Resp2TypeBulkString == ResultType.BulkString) + if (reader.IsScalar) { string category = Normalize(null); var list = new List>>(); - var raw = result.GetString(); - if (raw is not null) + if (!reader.IsNull) { - using var reader = new StringReader(raw); - while (reader.ReadLine() is string line) + using var stringReader = new StringReader(reader.ReadString()!); + while (stringReader.ReadLine() is string line) { if (string.IsNullOrWhiteSpace(line)) continue; if (line.StartsWith("# ")) @@ -1354,20 +1416,20 @@ private static string Normalize(string? category) => category.IsNullOrWhiteSpace() ? "miscellaneous" : category.Trim(); } - private sealed class Int64DefaultValueProcessor : ResultProcessor + internal sealed class Int64DefaultValueProcessor : ResultProcessor { private readonly long _defaultValue; public Int64DefaultValueProcessor(long defaultValue) => _defaultValue = defaultValue; - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.IsNull) + if (reader.IsNull) { SetResult(message, _defaultValue); return true; } - if (result.Resp2TypeBulkString == ResultType.Integer && result.TryGetInt64(out var i64)) + if (reader.IsScalar && reader.TryReadInt64(out var i64)) { SetResult(message, i64); return true; @@ -1378,19 +1440,12 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private class Int64Processor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + if (reader.IsScalar && reader.TryReadInt64(out long i64)) { - case ResultType.Integer: - case ResultType.SimpleString: - case ResultType.BulkString: - if (result.TryGetInt64(out long i64)) - { - SetResult(message, i64); - return true; - } - break; + SetResult(message, i64); + return true; } return false; } @@ -1398,19 +1453,12 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private class Int32Processor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + if (reader.IsScalar && reader.TryReadInt64(out long i64)) { - case ResultType.Integer: - case ResultType.SimpleString: - case ResultType.BulkString: - if (result.TryGetInt64(out long i64)) - { - SetResult(message, checked((int)i64)); - return true; - } - break; + SetResult(message, checked((int)i64)); + return true; } return false; } @@ -1427,32 +1475,28 @@ private sealed class Int32EnumProcessor : ResultProcessor where T : unmana private Int32EnumProcessor() { } public static readonly Int32EnumProcessor Instance = new(); - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + // Accept integer, simple string, bulk string, or unit array + long i64; + if (reader.IsScalar && reader.TryReadInt64(out i64)) { - case ResultType.Integer: - case ResultType.SimpleString: - case ResultType.BulkString: - if (result.TryGetInt64(out long i64)) - { - Debug.Assert(Unsafe.SizeOf() == sizeof(int)); - int i32 = (int)i64; - SetResult(message, Unsafe.As(ref i32)); - return true; - } - break; - case ResultType.Array when result.ItemsCount == 1: // pick a single element from a unit vector - if (result.GetItems()[0].TryGetInt64(out i64)) - { - Debug.Assert(Unsafe.SizeOf() == sizeof(int)); - int i32 = (int)i64; - SetResult(message, Unsafe.As(ref i32)); - return true; - } - break; + // Direct scalar read } - return false; + else if (reader.IsAggregate && reader.AggregateLengthIs(1) + && reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out i64)) + { + // Unit array - read the single element + } + else + { + return false; + } + + Debug.Assert(Unsafe.SizeOf() == sizeof(int)); + int i32 = (int)i64; + SetResult(message, Unsafe.As(ref i32)); + return true; } } @@ -1461,57 +1505,56 @@ private sealed class Int32EnumArrayProcessor : ResultProcessor where T : private Int32EnumArrayProcessor() { } public static readonly Int32EnumArrayProcessor Instance = new(); - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeArray) - { - case ResultType.Array: - T[] arr; - if (result.IsNull) - { - arr = null!; - } - else - { - Debug.Assert(Unsafe.SizeOf() == sizeof(int)); - arr = result.ToArray(static (in RawResult x) => - { - int i32 = (int)x.AsRedisValue(); - return Unsafe.As(ref i32); - })!; - } - SetResult(message, arr); - return true; - } - return false; + if (!reader.IsAggregate) return false; + + Debug.Assert(Unsafe.SizeOf() == sizeof(int)); + var arr = reader.ReadPastArray( + static (ref r) => + { + int i32 = (int)r.ReadInt64(); + return Unsafe.As(ref i32); + }, + scalar: true); + + SetResult(message, arr!); + return true; } } - private sealed class PubSubNumSubProcessor : Int64Processor + private sealed class PubSubNumSubProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.Resp2TypeArray == ResultType.Array) + if (reader.IsAggregate // name/count pairs + && reader.TryMoveNext() && reader.IsScalar // name, ignored + && reader.TryMoveNext() && reader.IsScalar // count + && reader.TryReadInt64(out long val) // parse the count + && !reader.TryMoveNext()) // no more elements { - var arr = result.GetItems(); - if (arr.Length == 2 && arr[1].TryGetInt64(out long val)) - { - SetResult(message, val); - return true; - } + SetResult(message, val); + return true; } - return base.SetResultCore(connection, message, result); + + return false; } } private sealed class NullableDoubleArrayProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.Resp2TypeArray == ResultType.Array && !result.IsNull) + if (reader.IsAggregate) { - var arr = result.GetItemsAsDoubles()!; - SetResult(message, arr); + var arr = reader.ReadPastArray( + static (ref r) => + { + if (r.IsNull) return (double?)null; + return r.TryReadDouble(out var val) ? val : null; + }, + scalar: true); + SetResult(message, arr!); return true; } return false; @@ -1520,24 +1563,20 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class NullableDoubleProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + if (reader.IsScalar) { - case ResultType.Integer: - case ResultType.SimpleString: - case ResultType.BulkString: - if (result.IsNull) - { - SetResult(message, null); - return true; - } - if (result.TryGetDouble(out double val)) - { - SetResult(message, val); - return true; - } - break; + if (reader.IsNull) + { + SetResult(message, null); + return true; + } + if (reader.TryReadDouble(out double val)) + { + SetResult(message, val); + return true; + } } return false; } @@ -1545,35 +1584,40 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class NullableInt64Processor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + if (reader.IsScalar) + { + if (reader.IsNull) + { + SetResult(message, null); + return true; + } + + if (reader.TryReadInt64(out var i64)) + { + SetResult(message, i64); + return true; + } + } + + // handle unit arrays with a scalar + if (reader.IsAggregate && reader.TryMoveNext() && reader.IsScalar) { - case ResultType.Integer: - case ResultType.SimpleString: - case ResultType.BulkString: - if (result.IsNull) + if (reader.IsNull) + { + if (!reader.TryMoveNext()) // only if unit, else ignore { SetResult(message, null); return true; } - if (result.TryGetInt64(out long i64)) - { - SetResult(message, i64); - return true; - } - break; - case ResultType.Array: - var items = result.GetItems(); - if (items.Length == 1) - { // treat an array of 1 like a single reply - if (items[0].TryGetInt64(out long value)) - { - SetResult(message, value); - return true; - } - } - break; + } + else if (reader.TryReadInt64(out var i64) && !reader.TryMoveNext()) + { + // treat an array of 1 like a single reply + SetResult(message, i64); + return true; + } } return false; } @@ -1581,13 +1625,18 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class ExpireResultArrayProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.Resp2TypeArray == ResultType.Array || result.IsNull) + if (reader.IsAggregate) { - var arr = result.ToArray((in RawResult x) => (ExpireResult)(long)x.AsRedisValue())!; - - SetResult(message, arr); + var arr = reader.ReadPastArray( + static (ref r) => + { + r.TryReadInt64(out var val); + return (ExpireResult)val; + }, + scalar: true); + SetResult(message, arr!); return true; } return false; @@ -1596,13 +1645,18 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class PersistResultArrayProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.Resp2TypeArray == ResultType.Array || result.IsNull) + if (reader.IsAggregate) { - var arr = result.ToArray((in RawResult x) => (PersistResult)(long)x.AsRedisValue())!; - - SetResult(message, arr); + var arr = reader.ReadPastArray( + static (ref r) => + { + r.TryReadInt64(out var val); + return (PersistResult)val; + }, + scalar: true); + SetResult(message, arr!); return true; } return false; @@ -1617,27 +1671,25 @@ public RedisChannelArrayProcessor(RedisChannel.RedisChannelOptions options) this.options = options; } - private readonly struct ChannelState // I would use a value-tuple here, but that is binding hell + // think "value-tuple", just: without the dependency hell on netfx + private readonly struct ChannelState(PhysicalConnection connection, RedisChannel.RedisChannelOptions options) { - public readonly byte[]? Prefix; - public readonly RedisChannel.RedisChannelOptions Options; - public ChannelState(byte[]? prefix, RedisChannel.RedisChannelOptions options) - { - Prefix = prefix; - Options = options; - } + public readonly PhysicalConnection Connection = connection; + public readonly RedisChannel.RedisChannelOptions Options = options; } - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeArray) + if (reader.IsAggregate) { - case ResultType.Array: - var final = result.ToArray( - (in RawResult item, in ChannelState state) => item.AsRedisChannel(state.Prefix, state.Options), - new ChannelState(connection.ChannelPrefix, options))!; - - SetResult(message, final); - return true; + var state = new ChannelState(connection, options); + var arr = reader.ReadPastArray( + ref state, + static (ref s, ref r) => + s.Connection.AsRedisChannel(in r, s.Options), + scalar: true); + SetResult(message, arr!); + return true; } return false; } @@ -1645,14 +1697,13 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class RedisKeyArrayProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeArray) + if (reader.IsAggregate) { - case ResultType.Array: - var arr = result.GetItemsAsKeys()!; - SetResult(message, arr); - return true; + var arr = reader.ReadPastArray(static (ref r) => r.ReadRedisKey(), scalar: true); + SetResult(message, arr!); + return true; } return false; } @@ -1660,15 +1711,12 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class RedisKeyProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + if (reader.IsScalar) { - case ResultType.Integer: - case ResultType.SimpleString: - case ResultType.BulkString: - SetResult(message, result.AsRedisKey()); - return true; + SetResult(message, reader.ReadByteArray()); + return true; } return false; } @@ -1676,18 +1724,24 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class RedisTypeProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + if (reader.IsScalar) { - case ResultType.SimpleString: - case ResultType.BulkString: - string s = result.GetString()!; - RedisType value; - if (string.Equals(s, "zset", StringComparison.OrdinalIgnoreCase)) value = Redis.RedisType.SortedSet; - else if (!Enum.TryParse(s, true, out value)) value = global::StackExchange.Redis.RedisType.Unknown; - SetResult(message, value); - return true; + RedisType redisType; + unsafe + { + if (!reader.TryParseScalar(&RedisTypeMetadata.TryParse, out redisType)) + { + // RESP null values and empty strings should map to None rather than Unknown + redisType = (reader.IsNull || reader.ScalarLengthIs(0)) + ? Redis.RedisType.None + : Redis.RedisType.Unknown; + } + } + + SetResult(message, redisType); + return true; } return false; } @@ -1695,22 +1749,23 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class RedisValueArrayProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + if (reader.IsAggregate) + { + var arr = reader.ReadPastRedisValues() ?? []; + SetResult(message, arr); + return true; + } + if (reader.IsScalar) { // allow a single item to pass explicitly pretending to be an array; example: SPOP {key} 1 - case ResultType.BulkString: - // If the result is nil, the result should be an empty array - var arr = result.IsNull - ? Array.Empty() - : new[] { result.AsRedisValue() }; - SetResult(message, arr); - return true; - case ResultType.Array: - arr = result.GetItemsAsValues()!; - SetResult(message, arr); - return true; + // If the result is nil, the result should be an empty array + var arr = reader.IsNull + ? Array.Empty() + : new[] { reader.ReadRedisValue() }; + SetResult(message, arr); + return true; } return false; } @@ -1718,12 +1773,12 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class Int64ArrayProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.Resp2TypeArray == ResultType.Array && !result.IsNull) + if (reader.IsAggregate) { - var arr = result.ToArray((in RawResult x) => (long)x.AsRedisValue())!; - SetResult(message, arr); + var arr = reader.ReadPastArray(static (ref r) => r.ReadInt64(), scalar: true); + SetResult(message, arr!); return true; } @@ -1733,15 +1788,13 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class NullableStringArrayProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeArray) + if (reader.IsAggregate) { - case ResultType.Array: - var arr = result.GetItemsAsStrings()!; - - SetResult(message, arr); - return true; + var arr = reader.ReadPastArray(static (ref r) => r.ReadString(), scalar: true); + SetResult(message, arr!); + return true; } return false; } @@ -1749,14 +1802,13 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class StringArrayProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeArray) + if (reader.IsAggregate) { - case ResultType.Array: - var arr = result.GetItemsAsStringsNotNullable()!; - SetResult(message, arr); - return true; + var arr = reader.ReadPastArray(static (ref r) => r.ReadString()!, scalar: true); + SetResult(message, arr!); + return true; } return false; } @@ -1764,12 +1816,12 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class BooleanArrayProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.Resp2TypeArray == ResultType.Array && !result.IsNull) + if (reader.IsAggregate) { - var arr = result.GetItemsAsBooleans()!; - SetResult(message, arr); + var arr = reader.ReadPastArray(static (ref r) => r.ReadBoolean(), scalar: true); + SetResult(message, arr!); return true; } return false; @@ -1778,15 +1830,20 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class RedisValueGeoPositionProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeArray) + if (reader.IsAggregate) { - case ResultType.Array: - var pos = result.GetItemsAsGeoPosition(); - - SetResult(message, pos); - return true; + if (reader.AggregateLengthIs(1)) + { + reader.MoveNext(); + SetResult(message, ParseGeoPosition(ref reader)); + } + else + { + SetResult(message, null); + } + return true; } return false; } @@ -1794,68 +1851,76 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class RedisValueGeoPositionArrayProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeArray) + if (reader.IsAggregate) { - case ResultType.Array: - var arr = result.GetItemsAsGeoPositionArray()!; - - SetResult(message, arr); - return true; + var arr = reader.ReadPastArray(static (ref r) => ParseGeoPosition(ref r)); + SetResult(message, arr!); + return true; } return false; } } - private sealed class GeoRadiusResultArrayProcessor : ResultProcessor + private static GeoPosition? ParseGeoPosition(ref RespReader reader) { - private static readonly GeoRadiusResultArrayProcessor[] instances; - private readonly GeoRadiusOptions options; - - static GeoRadiusResultArrayProcessor() + if (reader.IsAggregate && reader.AggregateLengthIs(2) + && reader.TryMoveNext() && reader.IsScalar && reader.TryReadDouble(out var longitude) + && reader.TryMoveNext() && reader.IsScalar && reader.TryReadDouble(out var latitude) + && !reader.TryMoveNext()) { - instances = new GeoRadiusResultArrayProcessor[8]; - for (int i = 0; i < 8; i++) instances[i] = new GeoRadiusResultArrayProcessor((GeoRadiusOptions)i); + return new GeoPosition(longitude, latitude); } + return null; + } + + private sealed class GeoRadiusResultArrayProcessor : ResultProcessor + { + private static readonly GeoRadiusResultArrayProcessor?[] instances = new GeoRadiusResultArrayProcessor?[8]; + private readonly GeoRadiusOptions options; public static GeoRadiusResultArrayProcessor Get(GeoRadiusOptions options) - { - int i = (int)options; - if (i < 0 || i >= instances.Length) throw new ArgumentOutOfRangeException(nameof(options)); - return instances[i]; - } + => instances[(int)options] ??= new(options); private GeoRadiusResultArrayProcessor(GeoRadiusOptions options) { this.options = options; } - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeArray) + if (reader.IsAggregate) { - case ResultType.Array: - var typed = result.ToArray( - (in RawResult item, in GeoRadiusOptions radiusOptions) => Parse(item, radiusOptions), options)!; - SetResult(message, typed); - return true; + var opts = options; + var typed = reader.ReadPastArray( + ref opts, + static (ref options, ref reader) => Parse(ref reader, options), + scalar: options == GeoRadiusOptions.None); + SetResult(message, typed!); + return true; } return false; } - private static GeoRadiusResult Parse(in RawResult item, GeoRadiusOptions options) + private static GeoRadiusResult Parse(ref RespReader reader, GeoRadiusOptions options) { if (options == GeoRadiusOptions.None) { // Without any WITH option specified, the command just returns a linear array like ["New York","Milan","Paris"]. - return new GeoRadiusResult(item.AsRedisValue(), null, null, null); + return new GeoRadiusResult(reader.ReadString(), null, null, null); } + // If WITHCOORD, WITHDIST or WITHHASH options are specified, the command returns an array of arrays, where each sub-array represents a single item. - var iter = item.GetItems().GetEnumerator(); + if (!reader.IsAggregate) + { + return default; + } + + reader.MoveNext(); // Move to first element in the sub-array // the first item in the sub-array is always the name of the returned item. - var member = iter.GetNext().AsRedisValue(); + var member = reader.ReadString(); /* The other information is returned in the following order as successive elements of the sub-array. The distance from the center as a floating point number, in the same unit specified in the radius. @@ -1865,14 +1930,25 @@ The geohash integer. double? distance = null; GeoPosition? position = null; long? hash = null; - if ((options & GeoRadiusOptions.WithDistance) != 0) { distance = (double?)iter.GetNext().AsRedisValue(); } - if ((options & GeoRadiusOptions.WithGeoHash) != 0) { hash = (long?)iter.GetNext().AsRedisValue(); } + + if ((options & GeoRadiusOptions.WithDistance) != 0) + { + reader.MoveNextScalar(); + distance = reader.ReadDouble(); + } + + if ((options & GeoRadiusOptions.WithGeoHash) != 0) + { + reader.MoveNextScalar(); + hash = reader.TryReadInt64(out var h) ? h : null; + } + if ((options & GeoRadiusOptions.WithCoordinates) != 0) { - var coords = iter.GetNext().GetItems(); - double longitude = (double)coords[0].AsRedisValue(), latitude = (double)coords[1].AsRedisValue(); - position = new GeoPosition(longitude, latitude); + reader.MoveNextAggregate(); + position = ParseGeoPosition(ref reader); } + return new GeoRadiusResult(member, distance, hash, position); } } @@ -1894,70 +1970,100 @@ The geohash integer. /// private sealed class LongestCommonSubsequenceProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) - { - switch (result.Resp2TypeArray) - { - case ResultType.Array when TryParse(result, out var value): - SetResult(message, value); - return true; - } - return false; - } - - private static bool TryParse(in RawResult result, out LCSMatchResult value) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - var topItems = result.GetItems(); - var matches = new LCSMatchResult.LCSMatch[topItems[1].GetItems().Length]; - int i = 0; - var matchesRawArray = topItems[1]; // skip the first element (title "matches") - foreach (var match in matchesRawArray.GetItems()) + if (reader.IsAggregate) { - var matchItems = match.GetItems(); + // Top-level array: ["matches", matches_array, "len", length_value] + // Use nominal access instead of positional + LCSMatchResult.LCSMatch[]? matchesArray = null; + long longestMatchLength = 0; - if (TryReadPosition(matchItems[0], out var first) - && TryReadPosition(matchItems[1], out var second) - && matchItems[2].TryGetInt64(out var length)) + var iter = reader.AggregateChildren(); + while (iter.MoveNext() && iter.Value.IsScalar) { - matches[i++] = new LCSMatchResult.LCSMatch(first, second, length); + LCSField field; + unsafe + { + if (!iter.Value.TryParseScalar(&LCSFieldMetadata.TryParse, out field)) + { + field = LCSField.Unknown; + } + } + + if (!iter.MoveNext()) break; // out of data + + switch (field) + { + case LCSField.Matches: + // Read the matches array + if (iter.Value.IsAggregate) + { + bool failed = false; + matchesArray = iter.Value.ReadPastArray(ref failed, static (ref failed, ref reader) => + { + // Don't even bother if we've already failed + if (!failed && reader.IsAggregate) + { + var matchChildren = reader.AggregateChildren(); + if (matchChildren.MoveNext() && TryReadPosition(ref matchChildren.Value, out var firstPos) + && matchChildren.MoveNext() && TryReadPosition(ref matchChildren.Value, out var secondPos) + && matchChildren.MoveNext() && matchChildren.Value.IsScalar && matchChildren.Value.TryReadInt64(out var length)) + { + return new LCSMatchResult.LCSMatch(firstPos, secondPos, length); + } + } + failed = true; + return default; + }); + + // Check if anything went wrong + if (failed) matchesArray = null; + } + break; + + case LCSField.Len: + // Read the length value + if (iter.Value.IsScalar) + { + longestMatchLength = iter.Value.TryReadInt64(out var totalLen) ? totalLen : 0; + } + break; + } } - else + + if (matchesArray is not null) { - value = default; - return false; + SetResult(message, new LCSMatchResult(matchesArray, longestMatchLength)); + return true; } } - var len = (long)topItems[3].AsRedisValue(); - - value = new LCSMatchResult(matches, len); - return true; + return false; } - private static bool TryReadPosition(in RawResult raw, out LCSMatchResult.LCSPosition position) + private static bool TryReadPosition(ref RespReader reader, 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; + if (!reader.IsAggregate) return false; + + if (!(reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out var start))) return false; + + if (!(reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out var end))) return false; + + position = new LCSMatchResult.LCSPosition(start, end); + return true; } } private sealed class RedisValueProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + if (reader.IsScalar) { - case ResultType.Integer: - case ResultType.SimpleString: - case ResultType.BulkString: - SetResult(message, result.AsRedisValue()); - return true; + SetResult(message, reader.ReadRedisValue()); + return true; } return false; } @@ -1965,18 +2071,13 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class RedisValueFromArrayProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + if (reader.IsAggregate && reader.AggregateLengthIs(1)) { - case ResultType.Array: - var items = result.GetItems(); - if (items.Length == 1) - { // treat an array of 1 like a single reply - SetResult(message, items[0].AsRedisValue()); - return true; - } - break; + reader.MoveNext(); + SetResult(message, reader.ReadRedisValue()); + return true; } return false; } @@ -1984,81 +2085,96 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class RoleProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - var items = result.GetItems(); - if (items.IsEmpty) + // Null, non-aggregate, empty, or non-scalar first element returns null Role + if (!(reader.IsAggregate && !reader.IsNull && reader.TryMoveNext() && reader.IsScalar)) { - return false; + SetResult(message, null!); + return true; + } + + RoleType roleType; + unsafe + { + if (!reader.TryParseScalar(&RoleTypeMetadata.TryParse, out roleType)) + { + roleType = RoleType.Unknown; + } } - ref var val = ref items[0]; - Role? role; - if (val.IsEqual(RedisLiterals.master)) role = ParsePrimary(items); - else if (val.IsEqual(RedisLiterals.slave)) role = ParseReplica(items, RedisLiterals.slave!); - else if (val.IsEqual(RedisLiterals.replica)) role = ParseReplica(items, RedisLiterals.replica!); // for when "slave" is deprecated - else if (val.IsEqual(RedisLiterals.sentinel)) role = ParseSentinel(items); - else role = new Role.Unknown(val.GetString()!); + var role = roleType switch + { + RoleType.Master => ParsePrimary(ref reader), + RoleType.Slave => ParseReplica(ref reader, "slave"), + RoleType.Replica => ParseReplica(ref reader, "replica"), + RoleType.Sentinel => ParseSentinel(ref reader), + _ => new Role.Unknown(reader.ReadString()!), + }; - if (role is null) return false; - SetResult(message, role); + SetResult(message, role!); return true; } - private static Role? ParsePrimary(in Sequence items) + private static Role? ParsePrimary(ref RespReader reader) { - if (items.Length < 3) + // Expect: offset (int64), replicas (array) + if (!(reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out var offset))) { return null; } - if (!items[1].TryGetInt64(out var offset)) + if (!(reader.TryMoveNext() && reader.IsAggregate)) { return null; } - var replicaItems = items[2].GetItems(); - ICollection replicas; - if (replicaItems.IsEmpty) - { - replicas = Array.Empty(); - } - else - { - replicas = new List((int)replicaItems.Length); - for (int i = 0; i < replicaItems.Length; i++) + var failed = false; + var replicas = reader.ReadPastArray( + ref failed, + static (ref isFailed, ref r) => { - if (TryParsePrimaryReplica(replicaItems[i].GetItems(), out var replica)) - { - replicas.Add(replica); - } - else + if (isFailed) return default; // bail early if already failed + if (!TryParsePrimaryReplica(ref r, out var replica)) { - return null; + isFailed = true; + return default; } - } - } + return replica; + }, + scalar: false) ?? []; + + if (failed) return null; return new Role.Master(offset, replicas); } - private static bool TryParsePrimaryReplica(in Sequence items, out Role.Master.Replica replica) + private static bool TryParsePrimaryReplica(ref RespReader reader, out Role.Master.Replica replica) { - if (items.Length < 3) + // Expect: [ip, port, offset] + if (!reader.IsAggregate || reader.IsNull) { replica = default; return false; } - var primaryIp = items[0].GetString()!; + // IP + if (!(reader.TryMoveNext() && reader.IsScalar)) + { + replica = default; + return false; + } + var primaryIp = reader.ReadString()!; - if (!items[1].TryGetInt64(out var primaryPort) || primaryPort > int.MaxValue) + // Port + if (!(reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out var primaryPort) && primaryPort <= int.MaxValue)) { replica = default; return false; } - if (!items[2].TryGetInt64(out var replicationOffset)) + // Offset + if (!(reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out var replicationOffset))) { replica = default; return false; @@ -2068,31 +2184,52 @@ private static bool TryParsePrimaryReplica(in Sequence items, out Rol return true; } - private static Role? ParseReplica(in Sequence items, string role) + private static Role? ParseReplica(ref RespReader reader, string role) { - if (items.Length < 5) + // Expect: masterIp, masterPort, state, offset + + // Master IP + if (!(reader.TryMoveNext() && reader.IsScalar)) { return null; } + var primaryIp = reader.ReadString()!; - var primaryIp = items[1].GetString()!; + // Master Port + if (!(reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out var primaryPort) && primaryPort <= int.MaxValue)) + { + return null; + } - if (!items[2].TryGetInt64(out var primaryPort) || primaryPort > int.MaxValue) + // Replication State + if (!(reader.TryMoveNext() && reader.IsScalar)) { return null; } - ref var val = ref items[3]; - string replicationState; - if (val.IsEqual(RedisLiterals.connect)) replicationState = RedisLiterals.connect!; - else if (val.IsEqual(RedisLiterals.connecting)) replicationState = RedisLiterals.connecting!; - else if (val.IsEqual(RedisLiterals.sync)) replicationState = RedisLiterals.sync!; - else if (val.IsEqual(RedisLiterals.connected)) replicationState = RedisLiterals.connected!; - else if (val.IsEqual(RedisLiterals.none)) replicationState = RedisLiterals.none!; - else if (val.IsEqual(RedisLiterals.handshake)) replicationState = RedisLiterals.handshake!; - else replicationState = val.GetString()!; + // this is just a long-winded way of avoiding some string allocs! + ReplicationState state; + unsafe + { + if (!reader.TryParseScalar(&ReplicationStateMetadata.TryParse, out state)) + { + state = ReplicationState.Unknown; + } + } - if (!items[4].TryGetInt64(out var replicationOffset)) + var replicationState = state switch + { + ReplicationState.Connect => "connect", + ReplicationState.Connecting => "connecting", + ReplicationState.Sync => "sync", + ReplicationState.Connected => "connected", + ReplicationState.None => "none", + ReplicationState.Handshake => "handshake", + _ => reader.ReadString()!, + }; + + // Replication Offset + if (!(reader.TryMoveNext() && reader.IsScalar && reader.TryReadInt64(out var replicationOffset))) { return null; } @@ -2100,35 +2237,39 @@ private static bool TryParsePrimaryReplica(in Sequence items, out Rol return new Role.Replica(role, primaryIp, (int)primaryPort, replicationState, replicationOffset); } - private static Role? ParseSentinel(in Sequence items) + private static Role? ParseSentinel(ref RespReader reader) { - if (items.Length < 2) + // Expect: array of master names + if (!(reader.TryMoveNext() && reader.IsAggregate)) { return null; } - var primaries = items[1].GetItemsAsStrings()!; - return new Role.Sentinel(primaries); + + var primaries = reader.ReadPastArray(static (ref r) => r.ReadString(), scalar: true); + return new Role.Sentinel(primaries ?? []); } } private sealed class ScriptResultProcessor : ResultProcessor { - public override bool SetResult(PhysicalConnection connection, Message message, in RawResult result) + public override bool SetResult(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.IsError && result.StartsWith(CommonReplies.NOSCRIPT)) + var copy = reader; + reader.MovePastBof(); + if (reader.IsError && reader.StartsWith(Literals.NOSCRIPT.U8)) { // scripts are not flushed individually, so assume the entire script cache is toast ("SCRIPT FLUSH") connection.BridgeCouldBeNull?.ServerEndPoint?.FlushScriptCache(); message.SetScriptUnavailable(); } // and apply usual processing for the rest - return base.SetResult(connection, message, result); + return base.SetResult(connection, message, ref copy); } // note that top-level error messages still get handled by SetResult, but nested errors // (is that a thing?) will be wrapped in the RedisResult - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (RedisResult.TryCreate(connection, result, out var value)) + if (RedisResult.TryCreate(connection, ref reader, out var value)) { SetResult(message, value); return true; @@ -2149,20 +2290,21 @@ public SingleStreamProcessor(bool skipStreamName = false) /// /// Handles . /// - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.IsNull) + if (reader.IsNull) { // Server returns 'nil' if no entries are returned for the given stream. SetResult(message, []); return true; } - if (result.Resp2TypeArray != ResultType.Array) + if (!reader.IsAggregate) { return false; } + var protocol = connection.Protocol.GetValueOrDefault(); StreamEntry[] entries; if (skipStreamName) @@ -2206,12 +2348,27 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes 6) "46" */ - ref readonly RawResult readResult = ref (result.Resp3Type == ResultType.Map ? ref result[1] : ref result[0][1]); - entries = ParseRedisStreamEntries(readResult); + if (protocol == RedisProtocol.Resp3) + { + // RESP3: map - skip the key, read the value + reader.MoveNext(); // skip key + reader.MoveNext(); // move to value + entries = ParseRedisStreamEntries(ref reader, protocol); + } + else + { + // RESP2: array - first element is array with [name, entries] + var iter = reader.AggregateChildren(); + iter.DemandNext(); // first stream + var streamIter = iter.Value.AggregateChildren(); + streamIter.DemandNext(); // skip stream name + streamIter.DemandNext(); // entries array + entries = ParseRedisStreamEntries(ref streamIter.Value, protocol); + } } else { - entries = ParseRedisStreamEntries(result); + entries = ParseRedisStreamEntries(ref reader, protocol); } SetResult(message, entries); @@ -2256,38 +2413,63 @@ Multibulk array. (note that XREADGROUP may include additional interior elements; see ParseRedisStreamEntries) */ - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.IsNull) + if (reader.IsNull) { // Nothing returned for any of the requested streams. The server returns 'nil'. SetResult(message, []); return true; } - if (result.Resp2TypeArray != ResultType.Array) + if (!reader.IsAggregate) { return false; } + var protocol = connection.Protocol.GetValueOrDefault(); RedisStream[] streams; - if (result.Resp3Type == ResultType.Map) // see SetResultCore for the shape delta between RESP2 and RESP3 + + if (reader.Prefix == RespPrefix.Map) // see SetResultCore for the shape delta between RESP2 and RESP3 { // root is a map of named inner-arrays - streams = RedisStreamInterleavedProcessor.Instance.ParseArray(result, false, out _, this)!; // null-checked + // RedisStreamInterleavedProcessor handles maps via the interleaved processor base + var processor = protocol == RedisProtocol.Resp2 ? RedisStreamInterleavedProcessor.Resp2 : RedisStreamInterleavedProcessor.Resp3; + streams = processor.ParseArray(ref reader, protocol, false, out _, null)!; // null-checked below } else { - streams = result.GetItems().ToArray( - (in RawResult item, in MultiStreamProcessor obj) => + streams = reader.ReadPastArray( + ref protocol, + static (ref protocol, ref itemReader) => { - var details = item.GetItems(); + if (!itemReader.IsAggregate) + { + throw new InvalidOperationException("Expected aggregate for stream"); + } + + // [0] = Name of the Stream + if (!itemReader.TryMoveNext()) + { + throw new InvalidOperationException("Expected stream name"); + } + var key = itemReader.ReadRedisKey(); + + // [1] = Multibulk Array of Stream Entries + if (!itemReader.TryMoveNext()) + { + throw new InvalidOperationException("Expected stream entries"); + } + var entries = StreamProcessorBase.ParseRedisStreamEntries(ref itemReader, protocol); - // details[0] = Name of the Stream - // details[1] = Multibulk Array of Stream Entries - return new RedisStream(key: details[0].AsRedisKey(), entries: obj.ParseRedisStreamEntries(details[1])!); + return new RedisStream(key: key, entries: entries); }, - this); + scalar: false)!; // null-checked below + + if (streams == null) + { + return false; + } } SetResult(message, streams); @@ -2299,13 +2481,19 @@ private sealed class RedisStreamInterleavedProcessor : ValuePairInterleavedProce { protected override bool AllowJaggedPairs => false; // we only use this on a flattened map - public static readonly RedisStreamInterleavedProcessor Instance = new(); - private RedisStreamInterleavedProcessor() + public static readonly RedisStreamInterleavedProcessor Resp2 = new(RedisProtocol.Resp2); + public static readonly RedisStreamInterleavedProcessor Resp3 = new(RedisProtocol.Resp3); + + private readonly RedisProtocol _protocol; + private RedisStreamInterleavedProcessor(RedisProtocol protocol) { + _protocol = protocol; } - protected override RedisStream Parse(in RawResult first, in RawResult second, object? state) - => new(key: first.AsRedisKey(), entries: ((MultiStreamProcessor)state!).ParseRedisStreamEntries(second)); + protected override RedisStream Parse(ref RespReader first, ref RespReader second, object? state) + { + return new(key: first.ReadRedisKey(), entries: StreamProcessorBase.ParseRedisStreamEntries(ref second, _protocol)); + } } /// @@ -2313,21 +2501,42 @@ protected override RedisStream Parse(in RawResult first, in RawResult second, ob /// internal sealed class StreamAutoClaimProcessor : StreamProcessorBase { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { // See https://redis.io/commands/xautoclaim for command documentation. // Note that the result should never be null, so intentionally treating it as a failure to parse here - if (result.Resp2TypeArray == ResultType.Array && !result.IsNull) + if (reader.IsAggregate && !reader.IsNull) { - var items = result.GetItems(); + int length = reader.AggregateLength(); + if (!(length == 2 || length == 3)) + { + return false; + } + + var iter = reader.AggregateChildren(); + var protocol = connection.Protocol.GetValueOrDefault(); // [0] The next start ID. - var nextStartId = items[0].AsRedisValue(); + iter.DemandNext(); + var nextStartId = iter.Value.ReadRedisValue(); + // [1] The array of StreamEntry's. - var entries = ParseRedisStreamEntries(items[1]); + iter.DemandNext(); + var entries = ParseRedisStreamEntries(ref iter.Value, protocol); + // [2] The array of message IDs deleted from the stream that were in the PEL. // This is not available in 6.2 so we need to be defensive when reading this part of the response. - var deletedIds = (items.Length == 3 ? items[2].GetItemsAsValues() : null) ?? []; + RedisValue[] deletedIds = []; + if (length == 3) + { + iter.DemandNext(); + if (iter.Value.IsAggregate && !iter.Value.IsNull) + { + deletedIds = iter.Value.ReadPastArray( + static (ref RespReader r) => r.ReadRedisValue(), + scalar: true)!; + } + } SetResult(message, new StreamAutoClaimResult(nextStartId, entries, deletedIds)); return true; @@ -2342,21 +2551,47 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes /// internal sealed class StreamAutoClaimIdsOnlyProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { // See https://redis.io/commands/xautoclaim for command documentation. // Note that the result should never be null, so intentionally treating it as a failure to parse here - if (result.Resp2TypeArray == ResultType.Array && !result.IsNull) + if (reader.IsAggregate && !reader.IsNull) { - var items = result.GetItems(); + int length = reader.AggregateLength(); + if (!(length == 2 || length == 3)) + { + return false; + } + + var iter = reader.AggregateChildren(); + + // [0] The next start ID. + iter.DemandNext(); + var nextStartId = iter.Value.ReadRedisValue(); + + // [1] The array of claimed message IDs. + iter.DemandNext(); + RedisValue[] claimedIds = []; + if (iter.Value.IsAggregate && !iter.Value.IsNull) + { + claimedIds = iter.Value.ReadPastArray( + static (ref RespReader r) => r.ReadRedisValue(), + scalar: true)!; + } - // [0] The next start ID. - var nextStartId = items[0].AsRedisValue(); - // [1] The array of claimed message IDs. - var claimedIds = items[1].GetItemsAsValues() ?? []; // [2] The array of message IDs deleted from the stream that were in the PEL. // This is not available in 6.2 so we need to be defensive when reading this part of the response. - var deletedIds = (items.Length == 3 ? items[2].GetItemsAsValues() : null) ?? []; + RedisValue[] deletedIds = []; + if (length == 3) + { + iter.DemandNext(); + if (iter.Value.IsAggregate && !iter.Value.IsNull) + { + deletedIds = iter.Value.ReadPastArray( + static (ref RespReader r) => r.ReadRedisValue(), + scalar: true)!; + } + } SetResult(message, new StreamAutoClaimIdsOnlyResult(nextStartId, claimedIds, deletedIds)); return true; @@ -2368,7 +2603,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes internal sealed class StreamConsumerInfoProcessor : InterleavedStreamInfoProcessorBase { - protected override StreamConsumerInfo ParseItem(in RawResult result) + protected override StreamConsumerInfo ParseItem(ref RespReader reader) { // Note: the base class passes a single consumer from the response into this method. @@ -2386,88 +2621,49 @@ protected override StreamConsumerInfo ParseItem(in RawResult result) // 4) (integer)1 // 5) idle // 6) (integer)83841983 - var arr = result.GetItems(); + if (!reader.IsAggregate) + { + return default; + } + string? name = default; int pendingMessageCount = default; long idleTimeInMilliseconds = default; - KeyValuePairParser.TryRead(arr, KeyValuePairParser.Name, ref name); - KeyValuePairParser.TryRead(arr, KeyValuePairParser.Pending, ref pendingMessageCount); - KeyValuePairParser.TryRead(arr, KeyValuePairParser.Idle, ref idleTimeInMilliseconds); - - return new StreamConsumerInfo(name!, pendingMessageCount, idleTimeInMilliseconds); - } - } - - private static class KeyValuePairParser - { - internal static readonly CommandBytes - Name = "name", - Consumers = "consumers", - Pending = "pending", - Idle = "idle", - LastDeliveredId = "last-delivered-id", - EntriesRead = "entries-read", - Lag = "lag", - IP = "ip", - Port = "port"; - - internal static bool TryRead(Sequence pairs, in CommandBytes key, ref long value) - { - var len = pairs.Length / 2; - for (int i = 0; i < len; i++) - { - if (pairs[i * 2].IsEqual(key) && pairs[(i * 2) + 1].TryGetInt64(out var tmp)) - { - value = tmp; - return true; - } - } - return false; - } - internal static bool TryRead(Sequence pairs, in CommandBytes key, ref long? value) - { - var len = pairs.Length / 2; - for (int i = 0; i < len; i++) + while (reader.TryMoveNext() && reader.IsScalar) { - if (pairs[i * 2].IsEqual(key) && pairs[(i * 2) + 1].TryGetInt64(out var tmp)) + StreamConsumerInfoField field; + unsafe { - value = tmp; - return true; + if (!reader.TryParseScalar(&StreamConsumerInfoFieldMetadata.TryParse, out field)) + { + field = StreamConsumerInfoField.Unknown; + } } - } - return false; - } - internal static bool TryRead(Sequence pairs, in CommandBytes key, ref int value) - { - long tmp = default; - if (TryRead(pairs, key, ref tmp)) - { - value = checked((int)tmp); - return true; - } - return false; - } + if (!reader.TryMoveNext()) break; - internal static bool TryRead(Sequence pairs, in CommandBytes key, [NotNullWhen(true)] ref string? value) - { - var len = pairs.Length / 2; - for (int i = 0; i < len; i++) - { - if (pairs[i * 2].IsEqual(key)) + switch (field) { - value = pairs[(i * 2) + 1].GetString()!; - return true; + case StreamConsumerInfoField.Name: + name = reader.ReadString(); + break; + case StreamConsumerInfoField.Pending when reader.TryReadInt64(out var pending): + pendingMessageCount = checked((int)pending); + break; + case StreamConsumerInfoField.Idle: + reader.TryReadInt64(out idleTimeInMilliseconds); + break; } } - return false; + + return new StreamConsumerInfo(name!, pendingMessageCount, idleTimeInMilliseconds); } } internal sealed class StreamGroupInfoProcessor : InterleavedStreamInfoProcessorBase { - protected override StreamGroupInfo ParseItem(in RawResult result) + protected override StreamGroupInfo ParseItem(ref RespReader reader) { // Note: the base class passes a single item from the response into this method. @@ -2497,18 +2693,51 @@ protected override StreamGroupInfo ParseItem(in RawResult result) // 10) (integer)1 // 11) "lag" // 12) (integer)1 - var arr = result.GetItems(); + if (!reader.IsAggregate) + { + return default; + } + string? name = default, lastDeliveredId = default; int consumerCount = default, pendingMessageCount = default; long entriesRead = default; long? lag = default; - KeyValuePairParser.TryRead(arr, KeyValuePairParser.Name, ref name); - KeyValuePairParser.TryRead(arr, KeyValuePairParser.Consumers, ref consumerCount); - KeyValuePairParser.TryRead(arr, KeyValuePairParser.Pending, ref pendingMessageCount); - KeyValuePairParser.TryRead(arr, KeyValuePairParser.LastDeliveredId, ref lastDeliveredId); - KeyValuePairParser.TryRead(arr, KeyValuePairParser.EntriesRead, ref entriesRead); - KeyValuePairParser.TryRead(arr, KeyValuePairParser.Lag, ref lag); + while (reader.TryMoveNext() && reader.IsScalar) + { + StreamGroupInfoField field; + unsafe + { + if (!reader.TryParseScalar(&StreamGroupInfoFieldMetadata.TryParse, out field)) + { + field = StreamGroupInfoField.Unknown; + } + } + + if (!reader.TryMoveNext()) break; + + switch (field) + { + case StreamGroupInfoField.Name: + name = reader.ReadString(); + break; + case StreamGroupInfoField.Consumers when reader.TryReadInt64(out var consumers): + consumerCount = checked((int)consumers); + break; + case StreamGroupInfoField.Pending when reader.TryReadInt64(out var pending): + pendingMessageCount = checked((int)pending); + break; + case StreamGroupInfoField.LastDeliveredId: + lastDeliveredId = reader.ReadString(); + break; + case StreamGroupInfoField.EntriesRead: + reader.TryReadInt64(out entriesRead); + break; + case StreamGroupInfoField.Lag when reader.TryReadInt64(out var lagValue): + lag = lagValue; + break; + } + } return new StreamGroupInfo(name!, consumerCount, pendingMessageCount, lastDeliveredId, entriesRead, lag); } @@ -2516,19 +2745,22 @@ protected override StreamGroupInfo ParseItem(in RawResult result) internal abstract class InterleavedStreamInfoProcessorBase : ResultProcessor { - protected abstract T ParseItem(in RawResult result); + protected abstract T ParseItem(ref RespReader reader); - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.Resp2TypeArray != ResultType.Array) + if (!reader.IsAggregate) { return false; } - var arr = result.GetItems(); - var parsedItems = arr.ToArray((in RawResult item, in InterleavedStreamInfoProcessorBase obj) => obj.ParseItem(item), this); + var self = this; + var parsedItems = reader.ReadPastArray( + ref self, + static (ref self, ref r) => self.ParseItem(ref r), + scalar: false); - SetResult(message, parsedItems); + SetResult(message, parsedItems!); return true; } } @@ -2555,15 +2787,15 @@ internal sealed class StreamInfoProcessor : StreamProcessorBase // 12) 1) 1526569544280-0 // 2) 1) "message" // 2) "banana" - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.Resp2TypeArray != ResultType.Array) + if (!reader.IsAggregate) { return false; } - var arr = result.GetItems(); - var max = arr.Length / 2; + int count = reader.AggregateLength(); + if ((count & 1) != 0) return false; // must be even (key-value pairs) long length = -1, radixTreeKeys = -1, radixTreeNodes = -1, groups = -1, entriesAdded = -1, idmpDuration = -1, idmpMaxsize = -1, @@ -2572,64 +2804,74 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes maxDeletedEntryId = Redis.RedisValue.Null, recordedFirstEntryId = Redis.RedisValue.Null; StreamEntry firstEntry = StreamEntry.Null, lastEntry = StreamEntry.Null; - var iter = arr.GetEnumerator(); - for (int i = 0; i < max; i++) + + var protocol = connection.Protocol.GetValueOrDefault(); + + while (reader.TryMoveNext() && reader.IsScalar) { - if (!iter.GetNext().TryParse(StreamInfoFieldMetadata.TryParse, out StreamInfoField field)) - field = StreamInfoField.Unknown; - ref RawResult value = ref iter.GetNext(); + StreamInfoField field; + unsafe + { + if (!reader.TryParseScalar(&StreamInfoFieldMetadata.TryParse, out field)) + { + field = StreamInfoField.Unknown; + } + } + + // Move to value + if (!reader.TryMoveNext()) break; switch (field) { case StreamInfoField.Length: - if (!value.TryGetInt64(out length)) return false; + if (!reader.TryReadInt64(out length)) return false; break; case StreamInfoField.RadixTreeKeys: - if (!value.TryGetInt64(out radixTreeKeys)) return false; + if (!reader.TryReadInt64(out radixTreeKeys)) return false; break; case StreamInfoField.RadixTreeNodes: - if (!value.TryGetInt64(out radixTreeNodes)) return false; + if (!reader.TryReadInt64(out radixTreeNodes)) return false; break; case StreamInfoField.Groups: - if (!value.TryGetInt64(out groups)) return false; + if (!reader.TryReadInt64(out groups)) return false; break; case StreamInfoField.LastGeneratedId: - lastGeneratedId = value.AsRedisValue(); + lastGeneratedId = reader.ReadRedisValue(); break; case StreamInfoField.FirstEntry: - firstEntry = ParseRedisStreamEntry(value); + firstEntry = ParseRedisStreamEntry(ref reader, protocol); break; case StreamInfoField.LastEntry: - lastEntry = ParseRedisStreamEntry(value); + lastEntry = ParseRedisStreamEntry(ref reader, protocol); break; // 7.0 case StreamInfoField.MaxDeletedEntryId: - maxDeletedEntryId = value.AsRedisValue(); + maxDeletedEntryId = reader.ReadRedisValue(); break; case StreamInfoField.RecordedFirstEntryId: - recordedFirstEntryId = value.AsRedisValue(); + recordedFirstEntryId = reader.ReadRedisValue(); break; case StreamInfoField.EntriesAdded: - if (!value.TryGetInt64(out entriesAdded)) return false; + if (!reader.TryReadInt64(out entriesAdded)) return false; break; // 8.6 case StreamInfoField.IdmpDuration: - if (!value.TryGetInt64(out idmpDuration)) return false; + if (!reader.TryReadInt64(out idmpDuration)) return false; break; case StreamInfoField.IdmpMaxsize: - if (!value.TryGetInt64(out idmpMaxsize)) return false; + if (!reader.TryReadInt64(out idmpMaxsize)) return false; break; case StreamInfoField.PidsTracked: - if (!value.TryGetInt64(out pidsTracked)) return false; + if (!reader.TryReadInt64(out pidsTracked)) return false; break; case StreamInfoField.IidsTracked: - if (!value.TryGetInt64(out iidsTracked)) return false; + if (!reader.TryReadInt64(out iidsTracked)) return false; break; case StreamInfoField.IidsAdded: - if (!value.TryGetInt64(out iidsAdded)) return false; + if (!reader.TryReadInt64(out iidsAdded)) return false; break; case StreamInfoField.IidsDuplicates: - if (!value.TryGetInt64(out iidsDuplicates)) return false; + if (!reader.TryReadInt64(out iidsDuplicates)) return false; break; } } @@ -2659,7 +2901,7 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes internal sealed class StreamPendingInfoProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { // Example: // > XPENDING mystream mygroup @@ -2670,38 +2912,66 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes // 2) "2" // 5) 1) 1) "Joe" // 2) "8" - if (result.Resp2TypeArray != ResultType.Array) + if (!(reader.IsAggregate && reader.AggregateLengthIs(4))) { return false; } - var arr = result.GetItems(); + var iter = reader.AggregateChildren(); - if (arr.Length != 4) + // Element 0: pending message count + iter.DemandNext(); + if (!iter.Value.TryReadInt64(out var pendingMessageCount)) { return false; } + // Element 1: lowest ID + iter.DemandNext(); + var lowestId = iter.Value.ReadRedisValue(); + + // Element 2: highest ID + iter.DemandNext(); + var highestId = iter.Value.ReadRedisValue(); + + // Element 3: consumers array (may be null) + iter.DemandNext(); StreamConsumer[]? consumers = null; // If there are no consumers as of yet for the given group, the last // item in the response array will be null. - ref RawResult third = ref arr[3]; - if (!third.IsNull) + if (iter.Value.IsAggregate && !iter.Value.IsNull) { - consumers = third.ToArray((in RawResult item) => - { - var details = item.GetItems(); - return new StreamConsumer( - name: details[0].AsRedisValue(), - pendingMessageCount: (int)details[1].AsRedisValue()); - }); + consumers = iter.Value.ReadPastArray( + static (ref RespReader consumerReader) => + { + if (!(consumerReader.IsAggregate && consumerReader.AggregateLengthIs(2))) + { + throw new InvalidOperationException("Expected array of 2 elements for consumer"); + } + + var consumerIter = consumerReader.AggregateChildren(); + + consumerIter.DemandNext(); + var name = consumerIter.Value.ReadRedisValue(); + + consumerIter.DemandNext(); + if (!consumerIter.Value.TryReadInt64(out var count)) + { + throw new InvalidOperationException("Expected integer for pending message count"); + } + + return new StreamConsumer( + name: name, + pendingMessageCount: checked((int)count)); + }, + scalar: false); } var pendingInfo = new StreamPendingInfo( - pendingMessageCount: (int)arr[0].AsRedisValue(), - lowestId: arr[1].AsRedisValue(), - highestId: arr[2].AsRedisValue(), + pendingMessageCount: checked((int)pendingMessageCount), + lowestId: lowestId, + highestId: highestId, consumers: consumers ?? []); SetResult(message, pendingInfo); @@ -2711,25 +2981,52 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes internal sealed class StreamPendingMessagesProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - if (result.Resp2TypeArray != ResultType.Array) + if (!reader.IsAggregate) { return false; } - var messageInfoArray = result.GetItems().ToArray((in RawResult item) => - { - var details = item.GetItems().GetEnumerator(); + var messageInfoArray = reader.ReadPastArray( + static (ref RespReader itemReader) => + { + if (!(itemReader.IsAggregate && itemReader.AggregateLengthIs(4))) + { + throw new InvalidOperationException("Expected array of 4 elements for pending message"); + } + + if (!itemReader.TryMoveNext()) + { + throw new InvalidOperationException("Expected message ID"); + } + var messageId = itemReader.ReadRedisValue(); + + if (!itemReader.TryMoveNext()) + { + throw new InvalidOperationException("Expected consumer name"); + } + var consumerName = itemReader.ReadRedisValue(); + + if (!itemReader.TryMoveNext() || !itemReader.TryReadInt64(out var idleTimeInMs)) + { + throw new InvalidOperationException("Expected integer for idle time"); + } - return new StreamPendingMessageInfo( - messageId: details.GetNext().AsRedisValue(), - consumerName: details.GetNext().AsRedisValue(), - idleTimeInMs: (long)details.GetNext().AsRedisValue(), - deliveryCount: (int)details.GetNext().AsRedisValue()); - }); + if (!itemReader.TryMoveNext() || !itemReader.TryReadInt64(out var deliveryCount)) + { + throw new InvalidOperationException("Expected integer for delivery count"); + } - SetResult(message, messageInfoArray); + return new StreamPendingMessageInfo( + messageId: messageId, + consumerName: consumerName, + idleTimeInMs: idleTimeInMs, + deliveryCount: checked((int)deliveryCount)); + }, + scalar: false); + + SetResult(message, messageInfoArray!); return true; } } @@ -2741,8 +3038,8 @@ private StreamNameValueEntryProcessor() { } - protected override NameValueEntry Parse(in RawResult first, in RawResult second, object? state) - => new NameValueEntry(first.AsRedisValue(), second.AsRedisValue()); + protected override NameValueEntry Parse(ref RespReader first, ref RespReader second, object? state) + => new NameValueEntry(first.ReadRedisValue(), second.ReadRedisValue()); } /// @@ -2751,9 +3048,9 @@ protected override NameValueEntry Parse(in RawResult first, in RawResult second, /// The type of the stream result. internal abstract class StreamProcessorBase : ResultProcessor { - protected static StreamEntry ParseRedisStreamEntry(in RawResult item) + protected static StreamEntry ParseRedisStreamEntry(ref RespReader reader, RedisProtocol protocol) { - if (item.IsNull || item.Resp2TypeArray != ResultType.Array) + if (!reader.IsAggregate || reader.IsNull) { return StreamEntry.Null; } @@ -2763,75 +3060,85 @@ protected static StreamEntry ParseRedisStreamEntry(in RawResult item) // optional (XREADGROUP with CLAIM): // [2] = idle time (in milliseconds) // [3] = delivery count - var entryDetails = item.GetItems(); + int length = reader.AggregateLength(); + var iter = reader.AggregateChildren(); + + iter.DemandNext(); + var id = iter.Value.ReadRedisValue(); + + iter.DemandNext(); + var values = ParseStreamEntryValues(ref iter.Value, protocol); - var id = entryDetails[0].AsRedisValue(); - var values = ParseStreamEntryValues(entryDetails[1]); // check for optional fields (XREADGROUP with CLAIM) - if (entryDetails.Length >= 4 && entryDetails[2].TryGetInt64(out var idleTimeInMs) && entryDetails[3].TryGetInt64(out var deliveryCount)) + if (length >= 4) { - return new StreamEntry( - id: id, - values: values, - idleTime: TimeSpan.FromMilliseconds(idleTimeInMs), - deliveryCount: checked((int)deliveryCount)); + iter.DemandNext(); + if (iter.Value.TryReadInt64(out var idleTimeInMs)) + { + iter.DemandNext(); + if (iter.Value.TryReadInt64(out var deliveryCount)) + { + return new StreamEntry( + id: id, + values: values, + idleTime: TimeSpan.FromMilliseconds(idleTimeInMs), + deliveryCount: checked((int)deliveryCount)); + } + } } + return new StreamEntry( id: id, values: values); } - protected internal StreamEntry[] ParseRedisStreamEntries(in RawResult result) => - result.GetItems().ToArray((in RawResult item, in StreamProcessorBase _) => ParseRedisStreamEntry(item), this); - - protected static NameValueEntry[] ParseStreamEntryValues(in RawResult result) - { - // The XRANGE, XREVRANGE, XREAD commands return stream entries - // in the following format. The name/value pairs are interleaved - // in the same fashion as the HGETALL response. - // - // 1) 1) 1518951480106-0 - // 2) 1) "sensor-id" - // 2) "1234" - // 3) "temperature" - // 4) "19.8" - // 2) 1) 1518951482479-0 - // 2) 1) "sensor-id" - // 2) "9999" - // 3) "temperature" - // 4) "18.2" - if (result.Resp2TypeArray != ResultType.Array || result.IsNull) + protected internal static StreamEntry[] ParseRedisStreamEntries(ref RespReader reader, RedisProtocol protocol) + { + if (!reader.IsAggregate || reader.IsNull) + { + return []; + } + + return reader.ReadPastArray( + ref protocol, + static (ref protocol, ref r) => ParseRedisStreamEntry(ref r, protocol), + scalar: false) ?? []; + } + + protected static NameValueEntry[] ParseStreamEntryValues(ref RespReader reader, RedisProtocol protocol) + { + if (!reader.IsAggregate || reader.IsNull) { return []; } - return StreamNameValueEntryProcessor.Instance.ParseArray(result, false, out _, null)!; // ! because we checked null above + return StreamNameValueEntryProcessor.Instance.ParseArray(ref reader, protocol, false, out _, null)!; } } private sealed class StringPairInterleavedProcessor : ValuePairInterleavedProcessorBase> { - protected override KeyValuePair Parse(in RawResult first, in RawResult second, object? state) => - new KeyValuePair(first.GetString()!, second.GetString()!); + protected override KeyValuePair Parse(ref RespReader first, ref RespReader second, object? state) => + new KeyValuePair(first.ReadString()!, second.ReadString()!); } private sealed class StringProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + if (reader.IsScalar) + { + SetResult(message, reader.ReadString()); + return true; + } + + if (reader.IsAggregate && reader.TryMoveNext() && reader.IsScalar) { - case ResultType.Integer: - case ResultType.SimpleString: - case ResultType.BulkString: - SetResult(message, result.GetString()); + // treat an array of 1 like a single reply + var value = reader.ReadString(); + if (!reader.TryMoveNext()) + { + SetResult(message, value); return true; - case ResultType.Array: - var arr = result.GetItems(); - if (arr.Length == 1) - { - SetResult(message, arr[0].GetString()); - return true; - } - break; + } } return false; } @@ -2839,25 +3146,14 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class TieBreakerProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeBulkString) + if (reader.IsScalar) { - case ResultType.SimpleString: - case ResultType.BulkString: - var tieBreaker = result.GetString()!; - SetResult(message, tieBreaker); - - try - { - if (connection.BridgeCouldBeNull?.ServerEndPoint is ServerEndPoint endpoint) - { - endpoint.TieBreakerResult = tieBreaker; - } - } - catch { } - - return true; + var tieBreaker = reader.ReadString(); + connection.BridgeCouldBeNull?.ServerEndPoint?.TieBreakerResult = tieBreaker; + SetResult(message, tieBreaker); + return true; } return false; } @@ -2872,23 +3168,29 @@ public TracerProcessor(bool establishConnection) this.establishConnection = establishConnection; } - public override bool SetResult(PhysicalConnection connection, Message message, in RawResult result) + public override bool SetResult(PhysicalConnection connection, Message message, ref RespReader reader) { - connection.BridgeCouldBeNull?.Multiplexer.OnInfoMessage($"got '{result}' for '{message.CommandAndKey}' on '{connection}'"); - var final = base.SetResult(connection, message, result); - if (result.IsError) + reader.MovePastBof(); + bool isError = reader.IsError; + var copy = reader; + + connection.BridgeCouldBeNull?.Multiplexer.OnInfoMessage($"got '{reader.Prefix}' for '{message.CommandAndKey}' on '{connection}'"); + var final = base.SetResult(connection, message, ref reader); + + if (isError) { - if (result.StartsWith(CommonReplies.authFail_trimmed) || result.StartsWith(CommonReplies.NOAUTH)) + reader = copy; // rewind and re-parse + if (reader.StartsWith(Literals.ERR_not_permitted.U8) || reader.StartsWith(Literals.NOAUTH.U8)) { - connection.RecordConnectionFailed(ConnectionFailureType.AuthenticationFailure, new Exception(result.ToString() + " Verify if the Redis password provided is correct. Attempted command: " + message.Command)); + connection.RecordConnectionFailed(ConnectionFailureType.AuthenticationFailure, new Exception(reader.GetOverview() + " Verify if the Redis password provided is correct. Attempted command: " + message.Command)); } - else if (result.StartsWith(CommonReplies.loading)) + else if (reader.StartsWith(Literals.LOADING.U8)) { connection.RecordConnectionFailed(ConnectionFailureType.Loading); } else { - connection.RecordConnectionFailed(ConnectionFailureType.ProtocolFailure, new RedisServerException(result.ToString())); + connection.RecordConnectionFailed(ConnectionFailureType.ProtocolFailure, new RedisServerException(reader.GetOverview())); } } @@ -2902,25 +3204,35 @@ public override bool SetResult(PhysicalConnection connection, Message message, i } [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0071:Simplify interpolation", Justification = "Allocations (string.Concat vs. string.Format)")] - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { bool happy; switch (message.Command) { case RedisCommand.ECHO: - happy = result.Resp2TypeBulkString == ResultType.BulkString && (!establishConnection || result.IsEqual(connection.BridgeCouldBeNull?.Multiplexer?.UniqueId)); + happy = reader.Prefix == RespPrefix.BulkString && (!establishConnection || reader.Is(connection.BridgeCouldBeNull?.Multiplexer?.UniqueId)); break; case RedisCommand.PING: // there are two different PINGs; "interactive" is a +PONG or +{your message}, // but subscriber returns a bulk-array of [ "pong", {your message} ] - switch (result.Resp2TypeArray) + Span buffer = stackalloc byte[8]; + switch (reader.Prefix) { - case ResultType.SimpleString: - happy = result.IsEqual(CommonReplies.PONG); + case RespPrefix.SimpleString: + var span = reader.TryGetSpan(out var tmp) ? tmp : reader.Buffer(buffer); + happy = Literals.PONG.Hash.IsCI(span); break; - case ResultType.Array when result.ItemsCount == 2: - var items = result.GetItems(); - happy = items[0].IsEqual(CommonReplies.PONG) && items[1].Payload.IsEmpty; + case RespPrefix.Array when reader.AggregateLengthIs(2): + var iter = reader.AggregateChildren(); + if (iter.MoveNext() && iter.Value.IsScalar) + { + var pongSpan = iter.Value.TryGetSpan(out var pongTmp) ? pongTmp : iter.Value.Buffer(buffer); + happy = Literals.PONG.Hash.IsCI(pongSpan) && iter.MoveNext() && iter.Value.IsScalar && iter.Value.ScalarIsEmpty(); + } + else + { + happy = false; + } break; default: happy = false; @@ -2928,10 +3240,10 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes } break; case RedisCommand.TIME: - happy = result.Resp2TypeArray == ResultType.Array && result.ItemsCount == 2; + happy = reader.Prefix == RespPrefix.Array && reader.AggregateLengthIs(2); break; case RedisCommand.EXISTS: - happy = result.Resp2TypeBulkString == ResultType.Integer; + happy = reader.Prefix == RespPrefix.Integer; break; default: happy = false; @@ -2951,106 +3263,205 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes { connection.RecordConnectionFailed( ConnectionFailureType.ProtocolFailure, - new InvalidOperationException($"unexpected tracer reply to {message.Command}: {result.ToString()}")); + new InvalidOperationException($"unexpected tracer reply to {message.Command}: {reader.GetOverview()}")); return false; } } } + /// + /// Filters out null values from an endpoint array efficiently. + /// + /// The array to filter, or null. + /// + /// - null if input is null. + /// - original array if no nulls found. + /// - empty array if all nulls. + /// - new array with nulls removed otherwise. + /// + private static EndPoint[]? FilterNullEndpoints(EndPoint?[]? endpoints) + { + if (endpoints is null) return null; + + // Count nulls in a single pass + int nullCount = 0; + for (int i = 0; i < endpoints.Length; i++) + { + if (endpoints[i] is null) nullCount++; + } + + // No nulls - return original array + if (nullCount == 0) return endpoints!; + + // All nulls - return empty array + if (nullCount == endpoints.Length) return []; + + // Some nulls - allocate new array and copy non-nulls + var result = new EndPoint[endpoints.Length - nullCount]; + int writeIndex = 0; + for (int i = 0; i < endpoints.Length; i++) + { + if (endpoints[i] is not null) + { + result[writeIndex++] = endpoints[i]!; + } + } + + return result; + } + private sealed class SentinelGetPrimaryAddressByNameProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeArray) + if (reader.IsAggregate && reader.AggregateLengthIs(2)) { - case ResultType.Array: - var items = result.GetItems(); - if (result.IsNull) - { - return true; - } - else if (items.Length == 2 && items[1].TryGetInt64(out var port)) - { - SetResult(message, Format.ParseEndPoint(items[0].GetString()!, checked((int)port))); - return true; - } - else if (items.Length == 0) - { - SetResult(message, null); - return true; - } - break; + reader.MoveNext(); + var host = reader.ReadString(); + reader.MoveNext(); + if (host is not null && reader.TryReadInt64(out var port)) + { + SetResult(message, Format.ParseEndPoint(host, checked((int)port))); + return true; + } + } + else if (reader.IsNull || (reader.IsAggregate && reader.AggregateLengthIs(0))) + { + SetResult(message, null); + return true; } return false; } } - private sealed class SentinelGetSentinelAddressesProcessor : ResultProcessor + private sealed partial class SentinelGetSentinelAddressesProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - List endPoints = []; - - switch (result.Resp2TypeArray) + if (reader.IsAggregate && !reader.IsNull) { - case ResultType.Array: - foreach (RawResult item in result.GetItems()) + var endpoints = reader.ReadPastArray( + static (ref RespReader itemReader) => { - var pairs = item.GetItems(); - string? ip = null; - int port = default; - if (KeyValuePairParser.TryRead(pairs, in KeyValuePairParser.IP, ref ip) - && KeyValuePairParser.TryRead(pairs, in KeyValuePairParser.Port, ref port)) + if (itemReader.IsAggregate) { - endPoints.Add(Format.ParseEndPoint(ip, port)); + // Parse key-value pairs by name: ["ip", "127.0.0.1", "port", "26379"] + // or ["port", "26379", "ip", "127.0.0.1"] - order doesn't matter + string? host = null; + long portValue = 0; + + while (itemReader.TryMoveNext() && itemReader.IsScalar) + { + SentinelAddressField field; + unsafe + { + if (!itemReader.TryParseScalar( + &SentinelAddressFieldMetadata.TryParse, + out field)) + { + field = SentinelAddressField.Unknown; + } + } + + // Check for second scalar value + if (!(itemReader.TryMoveNext() && itemReader.IsScalar)) break; + + switch (field) + { + case SentinelAddressField.Ip: + host = itemReader.ReadString(); + break; + + case SentinelAddressField.Port: + itemReader.TryReadInt64(out portValue); + break; + } + } + + if (host is not null && portValue > 0) + { + return Format.ParseEndPoint(host, checked((int)portValue)); + } } - } - SetResult(message, endPoints.ToArray()); - return true; + return null; + }, + scalar: false); - case ResultType.SimpleString: - // We don't want to blow up if the primary is not found - if (result.IsNull) - return true; - break; + var filtered = FilterNullEndpoints(endpoints); + if (filtered is not null) + { + SetResult(message, filtered); + return true; + } } return false; } } - private sealed class SentinelGetReplicaAddressesProcessor : ResultProcessor + private sealed partial class SentinelGetReplicaAddressesProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - List endPoints = []; - - switch (result.Resp2TypeArray) + if (reader.IsAggregate) { - case ResultType.Array: - foreach (RawResult item in result.GetItems()) + var endPoints = reader.ReadPastArray( + static (ref RespReader r) => { - var pairs = item.GetItems(); - string? ip = null; - int port = default; - if (KeyValuePairParser.TryRead(pairs, in KeyValuePairParser.IP, ref ip) - && KeyValuePairParser.TryRead(pairs, in KeyValuePairParser.Port, ref port)) + if (r.IsAggregate) { - endPoints.Add(Format.ParseEndPoint(ip, port)); + // Parse key-value pairs by name: ["ip", "127.0.0.1", "port", "6380"] + // or ["port", "6380", "ip", "127.0.0.1"] - order doesn't matter + string? host = null; + long portValue = 0; + + while (r.TryMoveNext() && r.IsScalar) + { + SentinelAddressField field; + unsafe + { + if (!r.TryParseScalar( + &SentinelAddressFieldMetadata.TryParse, + out field)) + { + field = SentinelAddressField.Unknown; + } + } + + // Check for second scalar value + if (!(r.TryMoveNext() && r.IsScalar)) break; + + switch (field) + { + case SentinelAddressField.Ip: + host = r.ReadString(); + break; + + case SentinelAddressField.Port: + r.TryReadInt64(out portValue); + break; + } + } + + if (host is not null && portValue > 0) + { + return Format.ParseEndPoint(host, checked((int)portValue)); + } } - } - break; + return null; + }, + scalar: false); - case ResultType.SimpleString: - // We don't want to blow up if the primary is not found - if (result.IsNull) - return true; - break; + var filtered = FilterNullEndpoints(endPoints); + if (filtered is not null && filtered.Length > 0) + { + SetResult(message, filtered); + return true; + } } - - if (endPoints.Count > 0) + else if (reader.IsScalar && reader.IsNull) { - SetResult(message, endPoints.ToArray()); + // We don't want to blow up if the primary is not found return true; } @@ -3060,34 +3471,38 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes private sealed class SentinelArrayOfArraysProcessor : ResultProcessor[][]> { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + private readonly struct ParseArrayState(StringPairInterleavedProcessor innerProcessor, RedisProtocol protocol, Message message) + { + public readonly StringPairInterleavedProcessor innerProcessor = innerProcessor; + public readonly RedisProtocol protocol = protocol; + public readonly Message message = message; + } + + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { if (StringPairInterleaved is not StringPairInterleavedProcessor innerProcessor) { return false; } - switch (result.Resp2TypeArray) + if (reader.IsAggregate && !reader.IsNull) { - case ResultType.Array: - var arrayOfArrays = result.GetItems(); - - var returnArray = result.ToArray[], StringPairInterleavedProcessor>( - (in RawResult rawInnerArray, in StringPairInterleavedProcessor proc) => + var protocol = connection.Protocol.GetValueOrDefault(); + var state = new ParseArrayState(innerProcessor, protocol, message); + var returnArray = reader.ReadPastArray( + ref state, + static (ref state, ref innerReader) => + { + if (!innerReader.IsAggregate) { - if (proc.TryParse(rawInnerArray, out KeyValuePair[]? kvpArray)) - { - return kvpArray!; - } - else - { - throw new ArgumentOutOfRangeException(nameof(rawInnerArray), $"Error processing {message.CommandAndKey}, could not decode array '{rawInnerArray}'"); - } - }, - innerProcessor)!; + throw new ArgumentOutOfRangeException(nameof(innerReader), $"Error processing {state.message.CommandAndKey}, expected array but got scalar"); + } + return state.innerProcessor.ParseArray(ref innerReader, state.protocol, false, out _, null)!; + }, + scalar: false); - SetResult(message, returnArray); - return true; + SetResult(message, returnArray!); + return true; } return false; } @@ -3108,34 +3523,27 @@ protected static void SetResult(Message? message, T value) internal abstract class ArrayResultProcessor : ResultProcessor { - protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) + protected override bool SetResultCore(PhysicalConnection connection, Message message, ref RespReader reader) { - switch (result.Resp2TypeArray) - { - case ResultType.Array: - var items = result.GetItems(); - T[] arr; - if (items.IsEmpty) - { - arr = []; - } - else + if (!reader.IsAggregate) return false; + + var self = this; + var arr = reader.ReadPastArray( + ref self, + static (ref s, ref r) => + { + if (!s.TryParse(ref r, out var parsed)) { - arr = new T[checked((int)items.Length)]; - int index = 0; - foreach (ref RawResult inner in items) - { - if (!TryParse(inner, out arr[index++])) - return false; - } + throw new InvalidOperationException("Failed to parse array element"); } - SetResult(message, arr); - return true; - default: - return false; - } + return parsed; + }, + scalar: false); + + SetResult(message, arr!); + return true; } - protected abstract bool TryParse(in RawResult raw, out T parsed); + protected abstract bool TryParse(ref RespReader reader, out T parsed); } } diff --git a/src/StackExchange.Redis/RoleType.cs b/src/StackExchange.Redis/RoleType.cs new file mode 100644 index 000000000..2edbc6618 --- /dev/null +++ b/src/StackExchange.Redis/RoleType.cs @@ -0,0 +1,50 @@ +using System; +using RESPite; + +namespace StackExchange.Redis; + +/// +/// Redis server role types. +/// +// [AsciiHash(nameof(RoleTypeMetadata))] +internal enum RoleType +{ + /// + /// Unknown or unrecognized role. + /// + [AsciiHash("")] + Unknown = 0, + + /// + /// Master role. + /// + [AsciiHash("master")] + Master, + + /// + /// Slave role (deprecated term). + /// + [AsciiHash("slave")] + Slave, + + /// + /// Replica role (preferred term for slave). + /// + [AsciiHash("replica")] + Replica, + + /// + /// Sentinel role. + /// + [AsciiHash("sentinel")] + Sentinel, +} + +/// +/// Metadata and parsing methods for RoleType. +/// +internal static partial class RoleTypeMetadata +{ + [AsciiHash] + internal static partial bool TryParse(ReadOnlySpan value, out RoleType role); +} diff --git a/src/StackExchange.Redis/SentinelAddressField.cs b/src/StackExchange.Redis/SentinelAddressField.cs new file mode 100644 index 000000000..bafc2ea0f --- /dev/null +++ b/src/StackExchange.Redis/SentinelAddressField.cs @@ -0,0 +1,37 @@ +using System; +using RESPite; + +namespace StackExchange.Redis; + +/// +/// Sentinel address field names. +/// +internal enum SentinelAddressField +{ + /// + /// Unknown or unrecognized field. + /// + [AsciiHash("")] + Unknown = 0, + + /// + /// IP address field. + /// + [AsciiHash("ip")] + Ip, + + /// + /// Port field. + /// + [AsciiHash("port")] + Port, +} + +/// +/// Metadata for SentinelAddressField enum parsing. +/// +internal static partial class SentinelAddressFieldMetadata +{ + [AsciiHash] + internal static partial bool TryParse(ReadOnlySpan value, out SentinelAddressField field); +} diff --git a/src/StackExchange.Redis/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs index abe8d8afb..664ef7206 100644 --- a/src/StackExchange.Redis/ServerEndPoint.cs +++ b/src/StackExchange.Redis/ServerEndPoint.cs @@ -380,7 +380,7 @@ internal void AddScript(string script, byte[] hash) } } - internal async Task AutoConfigureAsync(PhysicalConnection? connection, ILogger? log = null) + internal async Task AutoConfigureAsync(PhysicalConnection? connection, ILogger? log = null, CommandFlags extraFlags = CommandFlags.None) { if (!serverType.SupportsAutoConfigure()) { @@ -392,7 +392,7 @@ internal async Task AutoConfigureAsync(PhysicalConnection? connection, ILogger? log?.LogInformationAutoConfiguring(new(this)); var commandMap = Multiplexer.CommandMap; - const CommandFlags flags = CommandFlags.FireAndForget | CommandFlags.NoRedirect; + var flags = CommandFlags.FireAndForget | CommandFlags.NoRedirect | extraFlags; var features = GetFeatures(); Message msg; @@ -402,11 +402,11 @@ internal async Task AutoConfigureAsync(PhysicalConnection? connection, ILogger? { if (Multiplexer.RawConfig.KeepAlive <= 0) { - msg = Message.Create(-1, flags, RedisCommand.CONFIG, RedisLiterals.GET, RedisLiterals.timeout); + msg = Message.Create(-1, flags | Message.NoFlushFlag, RedisCommand.CONFIG, RedisLiterals.GET, RedisLiterals.timeout); msg.SetInternalCall(); await WriteDirectOrQueueFireAndForgetAsync(connection, msg, autoConfigProcessor).ForAwait(); } - msg = Message.Create(-1, flags, RedisCommand.CONFIG, RedisLiterals.GET, features.ReplicaCommands ? RedisLiterals.replica_read_only : RedisLiterals.slave_read_only); + msg = Message.Create(-1, flags | Message.NoFlushFlag, RedisCommand.CONFIG, RedisLiterals.GET, features.ReplicaCommands ? RedisLiterals.replica_read_only : RedisLiterals.slave_read_only); msg.SetInternalCall(); await WriteDirectOrQueueFireAndForgetAsync(connection, msg, autoConfigProcessor).ForAwait(); msg = Message.Create(-1, flags, RedisCommand.CONFIG, RedisLiterals.GET, RedisLiterals.databases); @@ -673,7 +673,7 @@ static async Task OnEstablishingAsyncAwaited(PhysicalConnection connection, Task var handshake = HandshakeAsync(connection, log); - if (handshake.Status != TaskStatus.RanToCompletion) + if (!handshake.IsCompletedSuccessfully) { return OnEstablishingAsyncAwaited(connection, handshake); } @@ -987,7 +987,7 @@ private async Task HandshakeAsync(PhysicalConnection connection, ILogger? log) if (Multiplexer.RawConfig.TryResp3()) // note this includes an availability check on HELLO { log?.LogInformationAuthenticatingViaHello(new(this)); - var hello = Message.CreateHello(3, user, password, clientName, CommandFlags.FireAndForget); + var hello = Message.CreateHello(3, user, password, clientName, CommandFlags.FireAndForget | Message.NoFlushFlag); hello.SetInternalCall(); await WriteDirectOrQueueFireAndForgetAsync(connection, hello, autoConfig ??= ResultProcessor.AutoConfigureProcessor.Create(log)).ForAwait(); @@ -1005,14 +1005,14 @@ private async Task HandshakeAsync(PhysicalConnection connection, ILogger? log) if (!string.IsNullOrWhiteSpace(user) && Multiplexer.CommandMap.IsAvailable(RedisCommand.AUTH)) { log?.LogInformationAuthenticatingUserPassword(new(this)); - msg = Message.Create(-1, CommandFlags.FireAndForget, RedisCommand.AUTH, (RedisValue)user, (RedisValue)password); + msg = Message.Create(-1, CommandFlags.FireAndForget | Message.NoFlushFlag, RedisCommand.AUTH, (RedisValue)user, (RedisValue)password); msg.SetInternalCall(); await WriteDirectOrQueueFireAndForgetAsync(connection, msg, ResultProcessor.DemandOK).ForAwait(); } else if (!string.IsNullOrWhiteSpace(password) && Multiplexer.CommandMap.IsAvailable(RedisCommand.AUTH)) { log?.LogInformationAuthenticatingPassword(new(this)); - msg = Message.Create(-1, CommandFlags.FireAndForget, RedisCommand.AUTH, (RedisValue)password); + msg = Message.Create(-1, CommandFlags.FireAndForget | Message.NoFlushFlag, RedisCommand.AUTH, (RedisValue)password); msg.SetInternalCall(); await WriteDirectOrQueueFireAndForgetAsync(connection, msg, ResultProcessor.DemandOK).ForAwait(); } @@ -1022,7 +1022,7 @@ private async Task HandshakeAsync(PhysicalConnection connection, ILogger? log) if (!string.IsNullOrWhiteSpace(clientName)) { log?.LogInformationSettingClientName(new(this), clientName); - msg = Message.Create(-1, CommandFlags.FireAndForget, RedisCommand.CLIENT, RedisLiterals.SETNAME, (RedisValue)clientName); + msg = Message.Create(-1, CommandFlags.FireAndForget | Message.NoFlushFlag, RedisCommand.CLIENT, RedisLiterals.SETNAME, (RedisValue)clientName); msg.SetInternalCall(); await WriteDirectOrQueueFireAndForgetAsync(connection, msg, ResultProcessor.DemandOK).ForAwait(); } @@ -1036,7 +1036,7 @@ private async Task HandshakeAsync(PhysicalConnection connection, ILogger? log) var libName = Multiplexer.GetFullLibraryName(); if (!string.IsNullOrWhiteSpace(libName)) { - msg = Message.Create(-1, CommandFlags.FireAndForget, RedisCommand.CLIENT, RedisLiterals.SETINFO, RedisLiterals.lib_name, libName); + msg = Message.Create(-1, CommandFlags.FireAndForget | Message.NoFlushFlag, RedisCommand.CLIENT, RedisLiterals.SETINFO, RedisLiterals.lib_name, libName); msg.SetInternalCall(); await WriteDirectOrQueueFireAndForgetAsync(connection, msg, ResultProcessor.DemandOK).ForAwait(); } @@ -1044,13 +1044,13 @@ private async Task HandshakeAsync(PhysicalConnection connection, ILogger? log) var version = ClientInfoSanitize(Utils.GetLibVersion()); if (!string.IsNullOrWhiteSpace(version)) { - msg = Message.Create(-1, CommandFlags.FireAndForget, RedisCommand.CLIENT, RedisLiterals.SETINFO, RedisLiterals.lib_ver, version); + msg = Message.Create(-1, CommandFlags.FireAndForget | Message.NoFlushFlag, RedisCommand.CLIENT, RedisLiterals.SETINFO, RedisLiterals.lib_ver, version); msg.SetInternalCall(); await WriteDirectOrQueueFireAndForgetAsync(connection, msg, ResultProcessor.DemandOK).ForAwait(); } } - msg = Message.Create(-1, CommandFlags.FireAndForget, RedisCommand.CLIENT, RedisLiterals.ID); + msg = Message.Create(-1, CommandFlags.FireAndForget | Message.NoFlushFlag, RedisCommand.CLIENT, RedisLiterals.ID); msg.SetInternalCall(); await WriteDirectOrQueueFireAndForgetAsync(connection, msg, autoConfig ??= ResultProcessor.AutoConfigureProcessor.Create(log)).ForAwait(); } @@ -1064,9 +1064,10 @@ private async Task HandshakeAsync(PhysicalConnection connection, ILogger? log) var connType = bridge.ConnectionType; if (connType == ConnectionType.Interactive) { - await AutoConfigureAsync(connection, log).ForAwait(); + await AutoConfigureAsync(connection, log, extraFlags: Message.NoFlushFlag).ForAwait(); } + // note that the final messages *are* flushed (no Message.NoFlushFlag) var tracer = GetTracerMessage(true); tracer = LoggingMessage.Create(log, tracer); log?.LogInformationSendingCriticalTracer(new(this), tracer.CommandAndKey); @@ -1085,7 +1086,7 @@ private async Task HandshakeAsync(PhysicalConnection connection, ILogger? log) } } log?.LogInformationFlushingOutboundBuffer(new(this)); - await connection.FlushAsync().ForAwait(); + connection.Flush(); } private void SetConfig(ref T field, T value, [CallerMemberName] string? caller = null) @@ -1108,6 +1109,8 @@ private void ClearMemoized() supportsPrimaryWrites = null; } + internal bool CanSimulateConnectionFailure => interactive?.CanSimulateConnectionFailure == true; + /// /// For testing only. /// diff --git a/src/StackExchange.Redis/ServerSelectionStrategy.cs b/src/StackExchange.Redis/ServerSelectionStrategy.cs index 48ba32a77..c6d56cf04 100644 --- a/src/StackExchange.Redis/ServerSelectionStrategy.cs +++ b/src/StackExchange.Redis/ServerSelectionStrategy.cs @@ -2,6 +2,7 @@ using System.Buffers; using System.Diagnostics; using System.Net; +using System.Runtime.CompilerServices; using System.Threading; namespace StackExchange.Redis @@ -62,42 +63,43 @@ internal sealed class ServerSelectionStrategy public ServerType ServerType { get; set; } = ServerType.Standalone; internal static int TotalSlots => RedisClusterSlotCount; - /// - /// Computes the hash-slot that would be used by the given key. - /// - /// The to determine a slot ID for. - public int HashSlot(in RedisKey key) + internal static int GetHashSlot(in RedisKey key) { - if (ServerType == ServerType.Standalone || key.IsNull) return NoSlot; + if (key.IsNull) return NoSlot; if (key.TryGetSimpleBuffer(out var arr)) // key was constructed from a byte[] { return GetClusterSlot(arr); } + var length = key.TotalLength(); + if (length <= 256) + { + Span span = stackalloc byte[length]; + var written = key.CopyTo(span); + Debug.Assert(written == length, "key length/write error"); + return GetClusterSlot(span); + } else { - var length = key.TotalLength(); - if (length <= 256) - { - Span span = stackalloc byte[length]; - var written = key.CopyTo(span); - Debug.Assert(written == length, "key length/write error"); - return GetClusterSlot(span); - } - else - { - arr = ArrayPool.Shared.Rent(length); - var span = new Span(arr, 0, length); - var written = key.CopyTo(span); - Debug.Assert(written == length, "key length/write error"); - var result = GetClusterSlot(span); - ArrayPool.Shared.Return(arr); - return result; - } + arr = ArrayPool.Shared.Rent(length); + var span = new Span(arr, 0, length); + var written = key.CopyTo(span); + Debug.Assert(written == length, "key length/write error"); + var result = GetClusterSlot(span); + ArrayPool.Shared.Return(arr); + return result; } } private byte[] ChannelPrefix => multiplexer?.ChannelPrefix ?? []; + /// + /// Computes the hash-slot that would be used by the given key. + /// + /// The to determine a slot ID for. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int HashSlot(in RedisKey key) + => ServerType is ServerType.Standalone ? NoSlot : GetHashSlot(key); + /// /// Computes the hash-slot that would be used by the given channel. /// diff --git a/src/StackExchange.Redis/SocketManager.cs b/src/StackExchange.Redis/SocketManager.cs index 146e576ff..fb66447ad 100644 --- a/src/StackExchange.Redis/SocketManager.cs +++ b/src/StackExchange.Redis/SocketManager.cs @@ -1,8 +1,6 @@ using System; -using System.IO.Pipelines; using System.Net; using System.Net.Sockets; -using System.Threading; using Pipelines.Sockets.Unofficial; namespace StackExchange.Redis @@ -12,6 +10,7 @@ namespace StackExchange.Redis /// the Socket.Select API and a dedicated reader-thread, which allows for fast responses /// even when the system is under ambient load. /// + [Obsolete("SocketManager is no longer used by StackExchange.Redis")] public sealed partial class SocketManager : IDisposable { /// @@ -24,7 +23,7 @@ public sealed partial class SocketManager : IDisposable /// /// The name for this . public SocketManager(string name) - : this(name, DEFAULT_WORKERS, SocketManagerOptions.None) { } + : this(name, 0, SocketManagerOptions.None) { } /// /// Creates a new instance. @@ -32,7 +31,7 @@ public SocketManager(string name) /// The name for this . /// Whether this should use high priority sockets. public SocketManager(string name, bool useHighPrioritySocketThreads) - : this(name, DEFAULT_WORKERS, UseHighPrioritySocketThreads(useHighPrioritySocketThreads)) { } + : this(name, 0, UseHighPrioritySocketThreads(useHighPrioritySocketThreads)) { } /// /// Creates a new (optionally named) instance. @@ -77,162 +76,30 @@ public enum SocketManagerOptions public SocketManager(string? name = null, int workerCount = 0, SocketManagerOptions options = SocketManagerOptions.None) { if (name.IsNullOrWhiteSpace()) name = GetType().Name; - if (workerCount <= 0) workerCount = DEFAULT_WORKERS; Name = name; - bool useHighPrioritySocketThreads = (options & SocketManagerOptions.UseHighPrioritySocketThreads) != 0, - useThreadPool = (options & SocketManagerOptions.UseThreadPool) != 0; - - const long Receive_PauseWriterThreshold = 4L * 1024 * 1024 * 1024; // receive: let's give it up to 4GiB of buffer for now - const long Receive_ResumeWriterThreshold = 3L * 1024 * 1024 * 1024; // (large replies get crazy big) - - var defaultPipeOptions = PipeOptions.Default; - - long send_PauseWriterThreshold = Math.Max( - 512 * 1024, // send: let's give it up to 0.5MiB - defaultPipeOptions.PauseWriterThreshold); // or the default, whichever is bigger - long send_ResumeWriterThreshold = Math.Max( - send_PauseWriterThreshold / 2, - defaultPipeOptions.ResumeWriterThreshold); - - Scheduler = PipeScheduler.ThreadPool; - if (!useThreadPool) - { - Scheduler = new DedicatedThreadPoolPipeScheduler( - name: name + ":IO", - workerCount: workerCount, - priority: useHighPrioritySocketThreads ? ThreadPriority.AboveNormal : ThreadPriority.Normal); - } - SendPipeOptions = new PipeOptions( - pool: defaultPipeOptions.Pool, - readerScheduler: Scheduler, - writerScheduler: Scheduler, - pauseWriterThreshold: send_PauseWriterThreshold, - resumeWriterThreshold: send_ResumeWriterThreshold, - minimumSegmentSize: Math.Max(defaultPipeOptions.MinimumSegmentSize, MINIMUM_SEGMENT_SIZE), - useSynchronizationContext: false); - ReceivePipeOptions = new PipeOptions( - pool: defaultPipeOptions.Pool, - readerScheduler: Scheduler, - writerScheduler: Scheduler, - pauseWriterThreshold: Receive_PauseWriterThreshold, - resumeWriterThreshold: Receive_ResumeWriterThreshold, - minimumSegmentSize: Math.Max(defaultPipeOptions.MinimumSegmentSize, MINIMUM_SEGMENT_SIZE), - useSynchronizationContext: false); + _ = workerCount; + _ = options; } /// /// Default / shared socket manager using a dedicated thread-pool. /// - public static SocketManager Shared - { - get - { - var shared = s_shared; - if (shared != null) return shared; - try - { - // note: we'll allow a higher max thread count on the shared one - shared = new SocketManager("DefaultSocketManager", DEFAULT_WORKERS * 2, false); - if (Interlocked.CompareExchange(ref s_shared, shared, null) == null) - shared = null; - } - finally { shared?.Dispose(); } - return Volatile.Read(ref s_shared); - } - } + public static SocketManager Shared => ThreadPool; /// /// Shared socket manager using the main thread-pool. /// - public static SocketManager ThreadPool - { - get - { - var shared = s_threadPool; - if (shared != null) return shared; - try - { - // note: we'll allow a higher max thread count on the shared one - shared = new SocketManager("ThreadPoolSocketManager", options: SocketManagerOptions.UseThreadPool); - if (Interlocked.CompareExchange(ref s_threadPool, shared, null) == null) - shared = null; - } - finally { shared?.Dispose(); } - return Volatile.Read(ref s_threadPool); - } - } + public static SocketManager ThreadPool { get; } = new("ThreadPoolSocketManager", options: SocketManagerOptions.UseThreadPool); /// /// Returns a string that represents the current object. /// /// A string that represents the current object. - public override string ToString() - { - var scheduler = SchedulerPool; - if (scheduler == null) return Name; - return $"{Name} - queue: {scheduler?.TotalServicedByQueue}, pool: {scheduler?.TotalServicedByPool}"; - } - - private static SocketManager? s_shared, s_threadPool; - - private const int DEFAULT_WORKERS = 5, MINIMUM_SEGMENT_SIZE = 8 * 1024; - - internal readonly PipeOptions SendPipeOptions, ReceivePipeOptions; - - internal PipeScheduler Scheduler { get; private set; } - - internal DedicatedThreadPoolPipeScheduler? SchedulerPool => Scheduler as DedicatedThreadPoolPipeScheduler; - - private enum CallbackOperation - { - Read, - Error, - } + public override string ToString() => Name; /// /// Releases all resources associated with this instance. /// - public void Dispose() - { - DisposeRefs(); - GC.SuppressFinalize(this); - OnDispose(); - } - - private void DisposeRefs() - { - // note: the scheduler *can't* be collected by itself - there will - // be threads, and those threads will be rooting the DedicatedThreadPool; - // but: we can lend a hand! We need to do this even in the finalizer - var tmp = SchedulerPool; - Scheduler = PipeScheduler.ThreadPool; - try { tmp?.Dispose(); } catch { } - } - - /// - /// Releases *appropriate* resources associated with this instance. - /// - ~SocketManager() => DisposeRefs(); - - internal static Socket CreateSocket(EndPoint endpoint) - { - var addressFamily = endpoint.AddressFamily; - var protocolType = addressFamily == AddressFamily.Unix ? ProtocolType.Unspecified : ProtocolType.Tcp; - - var socket = addressFamily == AddressFamily.Unspecified - ? new Socket(SocketType.Stream, protocolType) - : new Socket(addressFamily, SocketType.Stream, protocolType); - SocketConnection.SetRecommendedClientOptions(socket); - // socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.Linger, false); - return socket; - } - - partial void OnDispose(); - - internal string? GetState() - { - var s = SchedulerPool; - return s == null ? null : $"{s.AvailableCount} of {s.WorkerCount} available"; - } + public void Dispose() { } } } diff --git a/src/StackExchange.Redis/StackExchange.Redis.csproj b/src/StackExchange.Redis/StackExchange.Redis.csproj index 4bff8ce53..1848592fb 100644 --- a/src/StackExchange.Redis/StackExchange.Redis.csproj +++ b/src/StackExchange.Redis/StackExchange.Redis.csproj @@ -12,6 +12,9 @@ $(DefineConstants);VECTOR_SAFE $(DefineConstants);UNIX_SOCKET README.md + + + @@ -55,8 +58,6 @@ - - - + \ No newline at end of file diff --git a/src/StackExchange.Redis/StreamConsumerInfoField.cs b/src/StackExchange.Redis/StreamConsumerInfoField.cs new file mode 100644 index 000000000..3f0816763 --- /dev/null +++ b/src/StackExchange.Redis/StreamConsumerInfoField.cs @@ -0,0 +1,43 @@ +using System; +using RESPite; + +namespace StackExchange.Redis; + +/// +/// Fields that can appear in an XINFO CONSUMERS command response. +/// +internal enum StreamConsumerInfoField +{ + /// + /// Unknown or unrecognized field. + /// + [AsciiHash("")] + Unknown = 0, + + /// + /// Consumer name. + /// + [AsciiHash("name")] + Name, + + /// + /// Number of pending messages. + /// + [AsciiHash("pending")] + Pending, + + /// + /// Idle time in milliseconds. + /// + [AsciiHash("idle")] + Idle, +} + +/// +/// Metadata and parsing methods for StreamConsumerInfoField. +/// +internal static partial class StreamConsumerInfoFieldMetadata +{ + [AsciiHash] + internal static partial bool TryParse(ReadOnlySpan value, out StreamConsumerInfoField field); +} diff --git a/src/StackExchange.Redis/StreamGroupInfoField.cs b/src/StackExchange.Redis/StreamGroupInfoField.cs new file mode 100644 index 000000000..091ab3f6a --- /dev/null +++ b/src/StackExchange.Redis/StreamGroupInfoField.cs @@ -0,0 +1,61 @@ +using System; +using RESPite; + +namespace StackExchange.Redis; + +/// +/// Fields that can appear in an XINFO GROUPS command response. +/// +internal enum StreamGroupInfoField +{ + /// + /// Unknown or unrecognized field. + /// + [AsciiHash("")] + Unknown = 0, + + /// + /// Group name. + /// + [AsciiHash("name")] + Name, + + /// + /// Number of consumers in the group. + /// + [AsciiHash("consumers")] + Consumers, + + /// + /// Number of pending messages. + /// + [AsciiHash("pending")] + Pending, + + /// + /// Last delivered ID. + /// + [AsciiHash("last-delivered-id")] + LastDeliveredId, + + /// + /// Number of entries read. + /// + [AsciiHash("entries-read")] + EntriesRead, + + /// + /// Lag value. + /// + [AsciiHash("lag")] + Lag, +} + +/// +/// Metadata and parsing methods for StreamGroupInfoField. +/// +internal static partial class StreamGroupInfoFieldMetadata +{ + [AsciiHash] + internal static partial bool TryParse(ReadOnlySpan value, out StreamGroupInfoField field); +} 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); + } +} diff --git a/src/StackExchange.Redis/ValueCondition.cs b/src/StackExchange.Redis/ValueCondition.cs index c5cf4bd5a..51cbf6467 100644 --- a/src/StackExchange.Redis/ValueCondition.cs +++ b/src/StackExchange.Redis/ValueCondition.cs @@ -67,10 +67,10 @@ public override string ToString() case ConditionKind.ValueNotEquals: return $"IFNE {_value}"; case ConditionKind.DigestEquals: - var written = WriteHex(_value.DirectOverlappedBits64, stackalloc char[2 * DigestBytes]); + var written = WriteHex(_value.OverlappedValueInt64, stackalloc char[2 * DigestBytes]); return $"IFDEQ {written.ToString()}"; case ConditionKind.DigestNotEquals: - written = WriteHex(_value.DirectOverlappedBits64, stackalloc char[2 * DigestBytes]); + written = WriteHex(_value.OverlappedValueInt64, stackalloc char[2 * DigestBytes]); return $"IFDNE {written.ToString()}"; case ConditionKind.Always: return ""; @@ -249,33 +249,33 @@ static byte ToNibble(int b) _ => 0, }; - internal void WriteTo(PhysicalConnection physical) + internal void WriteTo(in MessageWriter writer) { switch (_kind) { case ConditionKind.Exists: - physical.WriteBulkString("XX"u8); + writer.WriteBulkString("XX"u8); break; case ConditionKind.NotExists: - physical.WriteBulkString("NX"u8); + writer.WriteBulkString("NX"u8); break; case ConditionKind.ValueEquals: - physical.WriteBulkString("IFEQ"u8); - physical.WriteBulkString(_value); + writer.WriteBulkString("IFEQ"u8); + writer.WriteBulkString(_value); break; case ConditionKind.ValueNotEquals: - physical.WriteBulkString("IFNE"u8); - physical.WriteBulkString(_value); + writer.WriteBulkString("IFNE"u8); + writer.WriteBulkString(_value); break; case ConditionKind.DigestEquals: - physical.WriteBulkString("IFDEQ"u8); - var written = WriteHex(_value.DirectOverlappedBits64, stackalloc byte[2 * DigestBytes]); - physical.WriteBulkString(written); + writer.WriteBulkString("IFDEQ"u8); + var written = WriteHex(_value.OverlappedValueInt64, stackalloc byte[2 * DigestBytes]); + writer.WriteBulkString(written); break; case ConditionKind.DigestNotEquals: - physical.WriteBulkString("IFDNE"u8); - written = WriteHex(_value.DirectOverlappedBits64, stackalloc byte[2 * DigestBytes]); - physical.WriteBulkString(written); + writer.WriteBulkString("IFDNE"u8); + written = WriteHex(_value.OverlappedValueInt64, stackalloc byte[2 * DigestBytes]); + writer.WriteBulkString(written); break; } } diff --git a/src/StackExchange.Redis/VectorSetAddMessage.cs b/src/StackExchange.Redis/VectorSetAddMessage.cs index 0beb65205..75dbbd630 100644 --- a/src/StackExchange.Redis/VectorSetAddMessage.cs +++ b/src/StackExchange.Redis/VectorSetAddMessage.cs @@ -60,31 +60,31 @@ internal static void SuppressFp32() { } internal static void RestoreFp32() { } #endif - protected abstract void WriteElement(bool packed, PhysicalConnection physical); + protected abstract void WriteElement(bool packed, in MessageWriter writer); - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { bool packed = UseFp32; // snapshot to avoid race in debug scenarios - physical.WriteHeader(Command, GetArgCount(packed)); - physical.Write(key); + writer.WriteHeader(Command, GetArgCount(packed)); + writer.Write(key); if (reducedDimensions.HasValue) { - physical.WriteBulkString("REDUCE"u8); - physical.WriteBulkString(reducedDimensions.GetValueOrDefault()); + writer.WriteBulkString("REDUCE"u8); + writer.WriteBulkString(reducedDimensions.GetValueOrDefault()); } - WriteElement(packed, physical); - if (useCheckAndSet) physical.WriteBulkString("CAS"u8); + WriteElement(packed, writer); + if (useCheckAndSet) writer.WriteBulkString("CAS"u8); switch (quantization) { case VectorSetQuantization.Int8: break; case VectorSetQuantization.None: - physical.WriteBulkString("NOQUANT"u8); + writer.WriteBulkString("NOQUANT"u8); break; case VectorSetQuantization.Binary: - physical.WriteBulkString("BIN"u8); + writer.WriteBulkString("BIN"u8); break; default: throw new ArgumentOutOfRangeException(nameof(quantization)); @@ -92,20 +92,20 @@ protected override void WriteImpl(PhysicalConnection physical) if (buildExplorationFactor.HasValue) { - physical.WriteBulkString("EF"u8); - physical.WriteBulkString(buildExplorationFactor.GetValueOrDefault()); + writer.WriteBulkString("EF"u8); + writer.WriteBulkString(buildExplorationFactor.GetValueOrDefault()); } - WriteAttributes(physical); + WriteAttributes(writer); if (maxConnections.HasValue) { - physical.WriteBulkString("M"u8); - physical.WriteBulkString(maxConnections.GetValueOrDefault()); + writer.WriteBulkString("M"u8); + writer.WriteBulkString(maxConnections.GetValueOrDefault()); } } - protected abstract void WriteAttributes(PhysicalConnection physical); + protected abstract void WriteAttributes(in MessageWriter writer); internal sealed class VectorSetAddMemberMessage( int db, @@ -136,32 +136,32 @@ public override int GetElementArgCount(bool packed) public override int GetAttributeArgCount() => _attributesJson is null ? 0 : 2; // [SETATTR {attributes}] - protected override void WriteElement(bool packed, PhysicalConnection physical) + protected override void WriteElement(bool packed, in MessageWriter writer) { if (packed) { - physical.WriteBulkString("FP32"u8); - physical.WriteBulkString(MemoryMarshal.AsBytes(values.Span)); + writer.WriteBulkString("FP32"u8); + writer.WriteBulkString(MemoryMarshal.AsBytes(values.Span)); } else { - physical.WriteBulkString("VALUES"u8); - physical.WriteBulkString(values.Length); + writer.WriteBulkString("VALUES"u8); + writer.WriteBulkString(values.Length); foreach (var val in values.Span) { - physical.WriteBulkString(val); + writer.WriteBulkString(val); } } - physical.WriteBulkString(element); + writer.WriteBulkString(element); } - protected override void WriteAttributes(PhysicalConnection physical) + protected override void WriteAttributes(in MessageWriter writer) { if (_attributesJson is not null) { - physical.WriteBulkString("SETATTR"u8); - physical.WriteBulkString(_attributesJson); + writer.WriteBulkString("SETATTR"u8); + writer.WriteBulkString(_attributesJson); } } } diff --git a/src/StackExchange.Redis/VectorSetInfo.cs b/src/StackExchange.Redis/VectorSetInfo.cs index afbc3fece..4255f8140 100644 --- a/src/StackExchange.Redis/VectorSetInfo.cs +++ b/src/StackExchange.Redis/VectorSetInfo.cs @@ -1,4 +1,3 @@ -using System; using System.Diagnostics.CodeAnalysis; using RESPite; diff --git a/src/StackExchange.Redis/VectorSetSimilaritySearchMessage.cs b/src/StackExchange.Redis/VectorSetSimilaritySearchMessage.cs index 1bbc418d5..ea84612c5 100644 --- a/src/StackExchange.Redis/VectorSetSimilaritySearchMessage.cs +++ b/src/StackExchange.Redis/VectorSetSimilaritySearchMessage.cs @@ -1,4 +1,5 @@ using System; +using RESPite.Messages; namespace StackExchange.Redis; @@ -33,20 +34,20 @@ internal sealed class VectorSetSimilaritySearchBySingleVectorMessage( internal override int GetSearchTargetArgCount(bool packed) => packed ? 2 : 2 + vector.Length; // FP32 {vector} or VALUES {num} {vector} - internal override void WriteSearchTarget(bool packed, PhysicalConnection physical) + internal override void WriteSearchTarget(bool packed, in MessageWriter writer) { if (packed) { - physical.WriteBulkString("FP32"u8); - physical.WriteBulkString(System.Runtime.InteropServices.MemoryMarshal.AsBytes(vector.Span)); + writer.WriteBulkString("FP32"u8); + writer.WriteBulkString(System.Runtime.InteropServices.MemoryMarshal.AsBytes(vector.Span)); } else { - physical.WriteBulkString("VALUES"u8); - physical.WriteBulkString(vector.Length); + writer.WriteBulkString("VALUES"u8); + writer.WriteBulkString(vector.Length); foreach (var val in vector.Span) { - physical.WriteBulkString(val); + writer.WriteBulkString(val); } } } @@ -68,15 +69,15 @@ internal sealed class VectorSetSimilaritySearchByMemberMessage( { internal override int GetSearchTargetArgCount(bool packed) => 2; // ELE {member} - internal override void WriteSearchTarget(bool packed, PhysicalConnection physical) + internal override void WriteSearchTarget(bool packed, in MessageWriter writer) { - physical.WriteBulkString("ELE"u8); - physical.WriteBulkString(member); + writer.WriteBulkString("ELE"u8); + writer.WriteBulkString(member); } } internal abstract int GetSearchTargetArgCount(bool packed); - internal abstract void WriteSearchTarget(bool packed, PhysicalConnection physical); + internal abstract void WriteSearchTarget(bool packed, in MessageWriter writer); public ResultProcessor?> GetResultProcessor() => VectorSetSimilaritySearchProcessor.Instance; @@ -87,11 +88,11 @@ private sealed class VectorSetSimilaritySearchProcessor : ResultProcessor.Create(length, clear: false); var target = lease.Span; int count = 0; - var iter = items.GetEnumerator(); + var iter = reader.AggregateChildren(); for (int i = 0; i < target.Length && iter.MoveNext(); i++) { - var member = iter.Current.AsRedisValue(); + var member = iter.Value.ReadRedisValue(); double score = double.NaN; string? attributesJson = null; if (internalNesting) { - if (!iter.MoveNext() || iter.Current.Resp2TypeArray != ResultType.Array) break; - if (!iter.Current.IsNull) + if (!iter.MoveNext() || !iter.Value.IsAggregate) break; + if (!iter.Value.IsNull) { - var subArray = iter.Current.GetItems(); - if (subArray.Length >= 1 && !subArray[0].TryGetDouble(out score)) break; - if (subArray.Length >= 2) attributesJson = subArray[1].GetString(); + int subLength = iter.Value.AggregateLength(); + var subIter = iter.Value.AggregateChildren(); + if (subLength >= 1 && subIter.MoveNext() && !subIter.Value.TryReadDouble(out score)) break; + if (subLength >= 2 && subIter.MoveNext()) attributesJson = subIter.Value.ReadString(); } } else { if (withScores) { - if (!iter.MoveNext() || !iter.Current.TryGetDouble(out score)) break; + if (!iter.MoveNext() || !iter.Value.TryReadDouble(out score)) break; } if (withAttribs) { if (!iter.MoveNext()) break; - attributesJson = iter.Current.GetString(); + attributesJson = iter.Value.ReadString(); } } @@ -194,67 +196,67 @@ private int GetArgCount(bool packed) return argCount; } - protected override void WriteImpl(PhysicalConnection physical) + protected override void WriteImpl(in MessageWriter writer) { // snapshot to avoid race in debug scenarios bool packed = VectorSetAddMessage.UseFp32; - physical.WriteHeader(Command, GetArgCount(packed)); + writer.WriteHeader(Command, GetArgCount(packed)); // Write key - physical.Write(key); + writer.Write(key); // Write search target: either "ELE {member}" or vector data - WriteSearchTarget(packed, physical); + WriteSearchTarget(packed, writer); if (HasFlag(VsimFlags.WithScores)) { - physical.WriteBulkString("WITHSCORES"u8); + writer.WriteBulkString("WITHSCORES"u8); } if (HasFlag(VsimFlags.WithAttributes)) { - physical.WriteBulkString("WITHATTRIBS"u8); + writer.WriteBulkString("WITHATTRIBS"u8); } // Write optional parameters if (HasFlag(VsimFlags.Count)) { - physical.WriteBulkString("COUNT"u8); - physical.WriteBulkString(count); + writer.WriteBulkString("COUNT"u8); + writer.WriteBulkString(count); } if (HasFlag(VsimFlags.Epsilon)) { - physical.WriteBulkString("EPSILON"u8); - physical.WriteBulkString(epsilon); + writer.WriteBulkString("EPSILON"u8); + writer.WriteBulkString(epsilon); } if (HasFlag(VsimFlags.SearchExplorationFactor)) { - physical.WriteBulkString("EF"u8); - physical.WriteBulkString(searchExplorationFactor); + writer.WriteBulkString("EF"u8); + writer.WriteBulkString(searchExplorationFactor); } if (HasFlag(VsimFlags.FilterExpression)) { - physical.WriteBulkString("FILTER"u8); - physical.WriteBulkString(filterExpression); + writer.WriteBulkString("FILTER"u8); + writer.WriteBulkString(filterExpression); } if (HasFlag(VsimFlags.MaxFilteringEffort)) { - physical.WriteBulkString("FILTER-EF"u8); - physical.WriteBulkString(maxFilteringEffort); + writer.WriteBulkString("FILTER-EF"u8); + writer.WriteBulkString(maxFilteringEffort); } if (HasFlag(VsimFlags.UseExactSearch)) { - physical.WriteBulkString("TRUTH"u8); + writer.WriteBulkString("TRUTH"u8); } if (HasFlag(VsimFlags.DisableThreading)) { - physical.WriteBulkString("NOTHREAD"u8); + writer.WriteBulkString("NOTHREAD"u8); } } diff --git a/src/StackExchange.Redis/YesNo.cs b/src/StackExchange.Redis/YesNo.cs new file mode 100644 index 000000000..0263d298e --- /dev/null +++ b/src/StackExchange.Redis/YesNo.cs @@ -0,0 +1,37 @@ +using System; +using RESPite; + +namespace StackExchange.Redis; + +/// +/// Yes/No values in Redis responses. +/// +internal enum YesNo +{ + /// + /// Unknown or unrecognized value. + /// + [AsciiHash("")] + Unknown = 0, + + /// + /// Yes value. + /// + [AsciiHash("yes")] + Yes, + + /// + /// No value. + /// + [AsciiHash("no")] + No, +} + +/// +/// Metadata and parsing methods for YesNo. +/// +internal static partial class YesNoMetadata +{ + [AsciiHash] + internal static partial bool TryParse(ReadOnlySpan value, out YesNo yesNo); +} diff --git a/tests/BasicTest/Program.cs b/tests/BasicTest/Program.cs index 2977c42c2..f6d1f39ac 100644 --- a/tests/BasicTest/Program.cs +++ b/tests/BasicTest/Program.cs @@ -45,7 +45,6 @@ protected override Job Configure(Job j) [Config(typeof(CustomConfig))] public class RedisBenchmarks : IDisposable { - private SocketManager mgr; private ConnectionMultiplexer connection; private IDatabase db; @@ -71,9 +70,7 @@ public void Setup() private static readonly RedisKey GeoKey = "GeoTest", IncrByKey = "counter", StringKey = "string", HashKey = "hash"; void IDisposable.Dispose() { - mgr?.Dispose(); connection?.Dispose(); - mgr = null; db = null; connection = null; GC.SuppressFinalize(this); diff --git a/tests/StackExchange.Redis.Benchmarks/AsciiHashSwitch.generated.cs b/tests/StackExchange.Redis.Benchmarks/AsciiHashSwitch.generated.cs new file mode 100644 index 000000000..935443117 --- /dev/null +++ b/tests/StackExchange.Redis.Benchmarks/AsciiHashSwitch.generated.cs @@ -0,0 +1,252 @@ +/* +using System; +using StackExchange.Redis; +#pragma warning disable CS8981 + +namespace StackExchange.Redis.Benchmarks +{ + partial class FastHashSwitch + { + static partial 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.FastHash.HashCS(value) == HashCS || global::RESPite.FastHash.EqualsCI(value, U8)); + } + static partial 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.FastHash.HashCS(value) == HashCS || global::RESPite.FastHash.EqualsCI(value, U8)); + } + static partial 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.FastHash.HashCS(value) == HashCS || global::RESPite.FastHash.EqualsCI(value, U8)); + } + static partial 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.FastHash.HashCS(value) == HashCS || global::RESPite.FastHash.EqualsCI(value, U8)); + } + static partial 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.FastHash.EqualsCI(value, U8); + } + static partial 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.FastHash.EqualsCI(value, U8); + } + static partial 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.FastHash.EqualsCI(value, U8); + } + static partial 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.FastHash.EqualsCI(value, U8); + } + static partial 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.FastHash.EqualsCI(value, U8); + } + static partial 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.FastHash.EqualsCI(value, U8); + } + static partial 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.FastHash.EqualsCI(value, U8); + } + static partial 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.FastHash.EqualsCI(value, U8); + } + static partial 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.FastHash.EqualsCI(value, U8); + } + static partial 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.FastHash.EqualsCI(value, U8); + } + static partial 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.FastHash.EqualsCI(value, U8); + } + static partial 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.FastHash.EqualsCI(value, U8); + } + static partial 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.FastHash.EqualsCI(value, U8); + } + static partial 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.FastHash.EqualsCI(value, U8); + } + static partial 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.FastHash.EqualsCI(value, U8); + } + static partial 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.FastHash.EqualsCI(value, U8); + } + static partial 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.FastHash.EqualsCI(value, U8); + } + static partial 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.FastHash.EqualsCI(value, U8); + } + static partial 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.FastHash.EqualsCI(value, U8); + } + static partial 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.FastHash.EqualsCI(value, U8); + } + } +} +*/ diff --git a/tests/StackExchange.Redis.Benchmarks/StackExchange.Redis.Benchmarks.csproj b/tests/StackExchange.Redis.Benchmarks/StackExchange.Redis.Benchmarks.csproj index 47359ac85..c6152af85 100644 --- a/tests/StackExchange.Redis.Benchmarks/StackExchange.Redis.Benchmarks.csproj +++ b/tests/StackExchange.Redis.Benchmarks/StackExchange.Redis.Benchmarks.csproj @@ -1,7 +1,7 @@  StackExchange.Redis MicroBenchmark Suite - net481;net8.0 + net481;net8.0;net10.0 Release Exe true diff --git a/tests/StackExchange.Redis.Tests/AbortOnConnectFailTests.cs b/tests/StackExchange.Redis.Tests/AbortOnConnectFailTests.cs index 25033fa1a..5f06b11d2 100644 --- a/tests/StackExchange.Redis.Tests/AbortOnConnectFailTests.cs +++ b/tests/StackExchange.Redis.Tests/AbortOnConnectFailTests.cs @@ -45,6 +45,7 @@ public async Task DisconnectAndReconnectThrowsConnectionExceptionSync() // Disconnect and don't allow re-connection conn.AllowConnect = false; var server = conn.GetServerSnapshot()[0]; + Assert.SkipUnless(server.CanSimulateConnectionFailure, "Skipping because server cannot simulate connection failure"); server.SimulateConnectionFailure(SimulatedFailureType.All); // Exception: The message timed out in the backlog attempting to send because no connection became available (400ms) - Last Connection Exception: SocketFailure (InputReaderCompleted, last-recv: 7) on 127.0.0.1:6379/Interactive, Idle/ReadAsync, last: PING, origin: SimulateConnectionFailure, outstanding: 0, last-read: 0s ago, last-write: 0s ago, keep-alive: 100s, state: ConnectedEstablished, mgr: 10 of 10 available, in: 0, in-pipe: 0, out-pipe: 0, last-heartbeat: never, last-mbeat: 0s ago, global: 0s ago, v: 2.6.120.51136, command=PING, timeout: 100, inst: 13, qu: 1, qs: 0, aw: False, bw: Inactive, last-in: 0, cur-in: 0, sync-ops: 2, async-ops: 0, serverEndpoint: 127.0.0.1:6379, conn-sec: n/a, aoc: 0, mc: 1/1/0, mgr: 10 of 10 available, clientName: CRAVERTOP7(SE.Redis-v2.6.120.51136), IOCP: (Busy=0,Free=1000,Min=16,Max=1000), WORKER: (Busy=2,Free=32765,Min=16,Max=32767), POOL: (Threads=33,QueuedItems=0,CompletedItems=6237,Timers=39), v: 2.6.120.51136 (Please take a look at this article for some common client-side issues that can cause timeouts: https://stackexchange.github.io/StackExchange.Redis/Timeouts) @@ -69,6 +70,7 @@ public async Task DisconnectAndNoReconnectThrowsConnectionExceptionAsync() // Disconnect and don't allow re-connection conn.AllowConnect = false; var server = conn.GetServerSnapshot()[0]; + Assert.SkipUnless(server.CanSimulateConnectionFailure, "Skipping because server cannot simulate connection failure"); server.SimulateConnectionFailure(SimulatedFailureType.All); // Exception: The message timed out in the backlog attempting to send because no connection became available (400ms) - Last Connection Exception: SocketFailure (InputReaderCompleted, last-recv: 7) on 127.0.0.1:6379/Interactive, Idle/ReadAsync, last: PING, origin: SimulateConnectionFailure, outstanding: 0, last-read: 0s ago, last-write: 0s ago, keep-alive: 100s, state: ConnectedEstablished, mgr: 8 of 10 available, in: 0, in-pipe: 0, out-pipe: 0, last-heartbeat: never, last-mbeat: 0s ago, global: 0s ago, v: 2.6.120.51136, command=PING, timeout: 100, inst: 0, qu: 0, qs: 0, aw: False, bw: CheckingForTimeout, last-in: 0, cur-in: 0, sync-ops: 1, async-ops: 1, serverEndpoint: 127.0.0.1:6379, conn-sec: n/a, aoc: 0, mc: 1/1/0, mgr: 8 of 10 available, clientName: CRAVERTOP7(SE.Redis-v2.6.120.51136), IOCP: (Busy=0,Free=1000,Min=16,Max=1000), WORKER: (Busy=6,Free=32761,Min=16,Max=32767), POOL: (Threads=33,QueuedItems=0,CompletedItems=5547,Timers=60), v: 2.6.120.51136 (Please take a look at this article for some common client-side issues that can cause timeouts: https://stackexchange.github.io/StackExchange.Redis/Timeouts) diff --git a/tests/StackExchange.Redis.Tests/AsyncTests.cs b/tests/StackExchange.Redis.Tests/AsyncTests.cs index cba1b1145..afe6ef7e2 100644 --- a/tests/StackExchange.Redis.Tests/AsyncTests.cs +++ b/tests/StackExchange.Redis.Tests/AsyncTests.cs @@ -16,6 +16,7 @@ public async Task AsyncTasksReportFailureIfServerUnavailable() await using var conn = Create(allowAdmin: true, shared: false, backlogPolicy: BacklogPolicy.FailFast); var server = conn.GetServer(TestConfig.Current.PrimaryServer, TestConfig.Current.PrimaryPort); + Assert.SkipUnless(server.CanSimulateConnectionFailure(), "Skipping because server cannot simulate connection failure"); RedisKey key = Me(); var db = conn.GetDatabase(); @@ -27,6 +28,7 @@ public async Task AsyncTasksReportFailureIfServerUnavailable() Assert.True(conn.Wait(b)); conn.AllowConnect = false; + server.SimulateConnectionFailure(SimulatedFailureType.All); var c = db.SetAddAsync(key, "c"); @@ -40,8 +42,8 @@ public async Task AsyncTasksReportFailureIfServerUnavailable() [Fact] public async Task AsyncTimeoutIsNoticed() { - await using var conn = Create(syncTimeout: 1000, asyncTimeout: 1000); - await using var pauseConn = Create(); + await using var conn = Create(syncTimeout: 1000, asyncTimeout: 1000, allowAdmin: true); + await using var pauseConn = Create(allowAdmin: true); var opt = ConfigurationOptions.Parse(conn.Configuration); if (!Debugger.IsAttached) { // we max the timeouts if a debugger is detected diff --git a/tests/StackExchange.Redis.Tests/BacklogTests.cs b/tests/StackExchange.Redis.Tests/BacklogTests.cs index e8ed1daf0..929b466b1 100644 --- a/tests/StackExchange.Redis.Tests/BacklogTests.cs +++ b/tests/StackExchange.Redis.Tests/BacklogTests.cs @@ -53,12 +53,14 @@ void PrintSnapshot(ConnectionMultiplexer muxer) await db.PingAsync(); var server = conn.GetServerSnapshot()[0]; + Assert.SkipUnless(server.CanSimulateConnectionFailure, "Skipping because server cannot simulate connection failure"); var stats = server.GetBridgeStatus(ConnectionType.Interactive); Assert.Equal(0, stats.BacklogMessagesPending); // Everything's normal // Fail the connection Log("Test: Simulating failure"); conn.AllowConnect = false; + server.SimulateConnectionFailure(SimulatedFailureType.All); Assert.False(conn.IsConnected); @@ -115,7 +117,6 @@ public async Task QueuesAndFlushesAfterReconnectingAsync() KeepAlive = 10000, AsyncTimeout = 5000, AllowAdmin = true, - SocketManager = SocketManager.ThreadPool, }; options.EndPoints.Add(TestConfig.Current.PrimaryServerAndPort); @@ -130,6 +131,7 @@ public async Task QueuesAndFlushesAfterReconnectingAsync() await db.PingAsync(); var server = conn.GetServerSnapshot()[0]; + Assert.SkipUnless(server.CanSimulateConnectionFailure, "Skipping because server cannot simulate connection failure"); var stats = server.GetBridgeStatus(ConnectionType.Interactive); Assert.Equal(0, stats.BacklogMessagesPending); // Everything's normal @@ -206,7 +208,6 @@ public async Task QueuesAndFlushesAfterReconnecting() KeepAlive = 10000, AsyncTimeout = 5000, AllowAdmin = true, - SocketManager = SocketManager.ThreadPool, }; options.EndPoints.Add(TestConfig.Current.PrimaryServerAndPort); @@ -221,6 +222,7 @@ public async Task QueuesAndFlushesAfterReconnecting() await db.PingAsync(); var server = conn.GetServerSnapshot()[0]; + Assert.SkipUnless(server.CanSimulateConnectionFailure, "Skipping because server cannot simulate connection failure"); var stats = server.GetBridgeStatus(ConnectionType.Interactive); Assert.Equal(0, stats.BacklogMessagesPending); // Everything's normal @@ -308,7 +310,6 @@ public async Task QueuesAndFlushesAfterReconnectingClusterAsync() options.KeepAlive = 10000; options.AsyncTimeout = 5000; options.AllowAdmin = true; - options.SocketManager = SocketManager.ThreadPool; await using var conn = await ConnectionMultiplexer.ConnectAsync(options, Writer); conn.ErrorMessage += (s, e) => Log($"Error Message {e.EndPoint}: {e.Message}"); @@ -327,6 +328,7 @@ public async Task QueuesAndFlushesAfterReconnectingClusterAsync() await UntilConditionAsync(TimeSpan.FromSeconds(10), () => (server = conn.SelectServer(getMsg)) != null); Assert.NotNull(server); + Assert.SkipUnless(server.CanSimulateConnectionFailure, "Skipping because server cannot simulate connection failure"); var stats = server.GetBridgeStatus(ConnectionType.Interactive); Assert.Equal(0, stats.BacklogMessagesPending); // Everything's normal @@ -414,7 +416,6 @@ public async Task TotalOutstandingIncludesBacklogQueue() KeepAlive = 10000, AsyncTimeout = 5000, AllowAdmin = true, - SocketManager = SocketManager.ThreadPool, }; options.EndPoints.Add(TestConfig.Current.PrimaryServerAndPort); @@ -424,6 +425,7 @@ public async Task TotalOutstandingIncludesBacklogQueue() await db.PingAsync(); var server = conn.GetServerSnapshot()[0]; + Assert.SkipUnless(server.CanSimulateConnectionFailure, "Skipping because server cannot simulate connection failure"); // Verify TotalOutstanding is 0 when connected and idle Log("Test: asserting connected counters"); diff --git a/tests/StackExchange.Redis.Tests/BoxUnboxTests.cs b/tests/StackExchange.Redis.Tests/BoxUnboxUnitTests.cs similarity index 65% rename from tests/StackExchange.Redis.Tests/BoxUnboxTests.cs rename to tests/StackExchange.Redis.Tests/BoxUnboxUnitTests.cs index 033a24839..762618535 100644 --- a/tests/StackExchange.Redis.Tests/BoxUnboxTests.cs +++ b/tests/StackExchange.Redis.Tests/BoxUnboxUnitTests.cs @@ -1,11 +1,12 @@ using System; +using System.Buffers; using System.Collections.Generic; using System.Text; using Xunit; namespace StackExchange.Redis.Tests; -public class BoxUnboxTests +public class BoxUnboxUnitTests { [Theory] [MemberData(nameof(RoundTripValues))] @@ -26,8 +27,9 @@ public void UnboxCommonValues(object value, RedisValue expected) [Theory] [MemberData(nameof(InternedValues))] - public void ReturnInternedBoxesForCommonValues(RedisValue value, bool expectSameReference) + public void ReturnInternedBoxesForCommonValues(RedisValue value, bool expectSameReference, string label) { + _ = label; object? x = value.Box(), y = value.Box(); Assert.Equal(expectSameReference, ReferenceEquals(x, y)); // check we got the right values! @@ -50,7 +52,7 @@ private static void AssertEqualGiveOrTakeNaN(RedisValue expected, RedisValue act Assert.Equal(expected, actual); } - private static readonly byte[] s_abc = Encoding.UTF8.GetBytes("abc"); + private static readonly byte[] s_abc = "abc"u8.ToArray(); public static IEnumerable RoundTripValues => new[] { @@ -138,31 +140,64 @@ public static IEnumerable InternedValues() for (int i = -20; i <= 40; i++) { bool expectInterned = i >= -1 & i <= 20; - yield return new object[] { (RedisValue)i, expectInterned }; - yield return new object[] { (RedisValue)(long)i, expectInterned }; - yield return new object[] { (RedisValue)(float)i, expectInterned }; - yield return new object[] { (RedisValue)(double)i, expectInterned }; + yield return new object[] { (RedisValue)i, expectInterned, "int" }; + yield return new object[] { (RedisValue)(long)i, expectInterned, "long" }; + yield return new object[] { (RedisValue)(float)i, expectInterned, "float" }; + yield return new object[] { (RedisValue)(double)i, expectInterned, "double" }; } - yield return new object[] { (RedisValue)float.NegativeInfinity, true }; - yield return new object[] { (RedisValue)(-0.5F), false }; - yield return new object[] { (RedisValue)0.5F, false }; - yield return new object[] { (RedisValue)float.PositiveInfinity, true }; - yield return new object[] { (RedisValue)float.NaN, true }; - - yield return new object[] { (RedisValue)double.NegativeInfinity, true }; - yield return new object[] { (RedisValue)(-0.5D), false }; - yield return new object[] { (RedisValue)0.5D, false }; - yield return new object[] { (RedisValue)double.PositiveInfinity, true }; - yield return new object[] { (RedisValue)double.NaN, true }; - - yield return new object[] { (RedisValue)true, true }; - yield return new object[] { (RedisValue)false, true }; - yield return new object[] { RedisValue.Null, true }; - yield return new object[] { RedisValue.EmptyString, true }; - yield return new object[] { (RedisValue)"abc", true }; - yield return new object[] { (RedisValue)s_abc, true }; - yield return new object[] { (RedisValue)new Memory(s_abc), false }; - yield return new object[] { (RedisValue)new ReadOnlyMemory(s_abc), false }; + yield return new object[] { (RedisValue)float.NegativeInfinity, true, "float" }; + yield return new object[] { (RedisValue)(-0.5F), false, "float" }; + yield return new object[] { (RedisValue)0.5F, false, "float" }; + yield return new object[] { (RedisValue)float.PositiveInfinity, true, "float" }; + yield return new object[] { (RedisValue)float.NaN, true, "float" }; + + yield return new object[] { (RedisValue)double.NegativeInfinity, true, "double" }; + yield return new object[] { (RedisValue)(-0.5D), false, "double" }; + yield return new object[] { (RedisValue)0.5D, false, "double" }; + yield return new object[] { (RedisValue)double.PositiveInfinity, true, "double" }; + yield return new object[] { (RedisValue)double.NaN, true, "double" }; + + yield return new object[] { (RedisValue)true, true, "bool" }; + yield return new object[] { (RedisValue)false, true, "bool" }; + yield return new object[] { RedisValue.Null, true, "Null" }; + yield return new object[] { RedisValue.EmptyString, true, "EmptyString" }; + yield return new object[] { (RedisValue)"abc", true, "string" }; + yield return new object[] { (RedisValue)s_abc, true, "byte[]" }; + + // memory that is a full array: fine, boxes as the array + Memory memRW = s_abc; + ReadOnlyMemory memRO = memRW; + yield return new object[] { (RedisValue)memRW, true, "byte[] Memory-full" }; + yield return new object[] { (RedisValue)memRO, true, "byte[] ReadOnlyMemory-full" }; + + // memory that is a partial array; boxes raw + memRW = s_abc.AsMemory(1, 2); + memRO = memRW; + yield return new object[] { (RedisValue)memRW, false, "byte[] Memory-partial" }; + yield return new object[] { (RedisValue)memRO, false, "byte[] ReadOnlyMemory-partial" }; + + var custom = new CustomMemoryManager(10); + memRW = custom.Memory; + memRO = memRW; + // memory that is a custom manager, full width; boxes raw + yield return new object[] { (RedisValue)memRW, false, "custom Memory-partial" }; + yield return new object[] { (RedisValue)memRO, false, "custom ReadOnlyMemory-partial" }; + + memRW = custom.Memory.Slice(1, 2); + memRO = memRW; + // memory that is a custom manager, partial width; boxes raw + yield return new object[] { (RedisValue)memRW, false, "custom Memory-partial" }; + yield return new object[] { (RedisValue)memRO, false, "custom ReadOnlyMemory-partial" }; + } + + private sealed class CustomMemoryManager(int size) : MemoryManager + { + private readonly byte[] _data = new byte[size]; + + public override Span GetSpan() => _data; + public override MemoryHandle Pin(int elementIndex = 0) => throw new NotSupportedException(); + public override void Unpin() { } + protected override void Dispose(bool disposing) { } } } diff --git a/tests/StackExchange.Redis.Tests/ClusterTests.cs b/tests/StackExchange.Redis.Tests/ClusterTests.cs index 781b65fef..117363075 100644 --- a/tests/StackExchange.Redis.Tests/ClusterTests.cs +++ b/tests/StackExchange.Redis.Tests/ClusterTests.cs @@ -137,7 +137,7 @@ public async Task TestIdentity() public async Task IntentionalWrongServer() { static string? StringGet(IServer server, RedisKey key, CommandFlags flags = CommandFlags.None) - => (string?)server.Execute("GET", [key], flags); + => (string?)server.Execute(0, "GET", [key], flags); await using var conn = Create(); @@ -669,14 +669,14 @@ public async Task MovedProfiling() Assert.NotNull(rightPrimaryNode); Assert.NotNull(rightPrimaryNode.EndPoint); - string? a = (string?)conn.GetServer(rightPrimaryNode.EndPoint).Execute("GET", key); + string? a = (string?)conn.GetServer(rightPrimaryNode.EndPoint).Execute(0, "GET", [key]); Assert.Equal(Value, a); // right primary var wrongPrimaryNode = config.Nodes.FirstOrDefault(x => !x.IsReplica && x.NodeId != rightPrimaryNode.NodeId); Assert.NotNull(wrongPrimaryNode); Assert.NotNull(wrongPrimaryNode.EndPoint); - string? b = (string?)conn.GetServer(wrongPrimaryNode.EndPoint).Execute("GET", key); + string? b = (string?)conn.GetServer(wrongPrimaryNode.EndPoint).Execute(0, "GET", [key]); Assert.Equal(Value, b); // wrong primary, allow redirect var msgs = profiler.GetSession().FinishProfiling().ToList(); diff --git a/tests/StackExchange.Redis.Tests/CommandTests.cs b/tests/StackExchange.Redis.Tests/CommandTests.cs deleted file mode 100644 index 42df92dd1..000000000 --- a/tests/StackExchange.Redis.Tests/CommandTests.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Net; -using Xunit; - -namespace StackExchange.Redis.Tests; - -public class CommandTests -{ - [Fact] - public void CommandByteLength() - { - Assert.Equal(31, CommandBytes.MaxLength); - } - - [Fact] - public void CheckCommandContents() - { - for (int len = 0; len <= CommandBytes.MaxLength; len++) - { - var s = new string('A', len); - CommandBytes b = s; - Assert.Equal(len, b.Length); - - var t = b.ToString(); - Assert.Equal(s, t); - - CommandBytes b2 = t; - Assert.Equal(b, b2); - - Assert.Equal(len == 0, ReferenceEquals(s, t)); - } - } - - [Fact] - public void Basic() - { - var config = ConfigurationOptions.Parse(".,$PING=p"); - Assert.Single(config.EndPoints); - config.SetDefaultPorts(); - Assert.Contains(new DnsEndPoint(".", 6379), config.EndPoints); - var map = config.CommandMap; - Assert.Equal("$PING=P", map.ToString()); - Assert.Equal(".:6379,$PING=P", config.ToString()); - } - - [Theory] - [InlineData("redisql.CREATE_STATEMENT")] - [InlineData("INSERTINTOTABLE1STMT")] - public void CanHandleNonTrivialCommands(string command) - { - var cmd = new CommandBytes(command); - Assert.Equal(command.Length, cmd.Length); - Assert.Equal(command.ToUpperInvariant(), cmd.ToString()); - - Assert.Equal(31, CommandBytes.MaxLength); - } -} diff --git a/tests/StackExchange.Redis.Tests/ConfigTests.cs b/tests/StackExchange.Redis.Tests/ConfigTests.cs index 3dfa4f99a..065907999 100644 --- a/tests/StackExchange.Redis.Tests/ConfigTests.cs +++ b/tests/StackExchange.Redis.Tests/ConfigTests.cs @@ -541,33 +541,6 @@ public void EndpointIteratorIsReliableOverChanges() Assert.False(iter.MoveNext()); } - [Fact] - public async Task ThreadPoolManagerIsDetected() - { - var config = new ConfigurationOptions - { - EndPoints = { { IPAddress.Loopback, 6379 } }, - SocketManager = SocketManager.ThreadPool, - }; - - await using var conn = ConnectionMultiplexer.Connect(config); - - Assert.Same(PipeScheduler.ThreadPool, conn.SocketManager?.Scheduler); - } - - [Fact] - public async Task DefaultThreadPoolManagerIsDetected() - { - var config = new ConfigurationOptions - { - EndPoints = { { IPAddress.Loopback, 6379 } }, - }; - - await using var conn = ConnectionMultiplexer.Connect(config); - - Assert.Same(SocketManager.Shared.Scheduler, conn.SocketManager?.Scheduler); - } - [Theory] [InlineData("myDNS:myPort,password=myPassword,connectRetry=3,connectTimeout=15000,syncTimeout=15000,defaultDatabase=0,abortConnect=false,ssl=true,sslProtocols=Tls12", SslProtocols.Tls12)] [InlineData("myDNS:myPort,password=myPassword,abortConnect=false,ssl=true,sslProtocols=Tls12", SslProtocols.Tls12)] diff --git a/tests/StackExchange.Redis.Tests/ConnectFailTimeoutTests.cs b/tests/StackExchange.Redis.Tests/ConnectFailTimeoutTests.cs index 6a7e253d7..ed9a09cb6 100644 --- a/tests/StackExchange.Redis.Tests/ConnectFailTimeoutTests.cs +++ b/tests/StackExchange.Redis.Tests/ConnectFailTimeoutTests.cs @@ -13,6 +13,7 @@ public async Task NoticesConnectFail() await using var conn = Create(allowAdmin: true, shared: false, backlogPolicy: BacklogPolicy.FailFast); var server = conn.GetServer(conn.GetEndPoints()[0]); + Assert.SkipUnless(server.CanSimulateConnectionFailure(), "Skipping because server cannot simulate connection failure"); await RunBlockingSynchronousWithExtraThreadAsync(InnerScenario).ForAwait(); diff --git a/tests/StackExchange.Redis.Tests/ConnectingFailDetectionTests.cs b/tests/StackExchange.Redis.Tests/ConnectingFailDetectionTests.cs index a905c613a..c58e5b161 100644 --- a/tests/StackExchange.Redis.Tests/ConnectingFailDetectionTests.cs +++ b/tests/StackExchange.Redis.Tests/ConnectingFailDetectionTests.cs @@ -21,6 +21,7 @@ public async Task FastNoticesFailOnConnectingSyncCompletion() await db.PingAsync(); var server = conn.GetServer(conn.GetEndPoints()[0]); + Assert.SkipUnless(server.CanSimulateConnectionFailure(), "Skipping because server cannot simulate connection failure"); var server2 = conn.GetServer(conn.GetEndPoints()[1]); conn.AllowConnect = false; @@ -61,6 +62,7 @@ public async Task FastNoticesFailOnConnectingAsyncCompletion() await db.PingAsync(); var server = conn.GetServer(conn.GetEndPoints()[0]); + Assert.SkipUnless(server.CanSimulateConnectionFailure(), "Skipping because server cannot simulate connection failure"); var server2 = conn.GetServer(conn.GetEndPoints()[1]); conn.AllowConnect = false; @@ -121,6 +123,7 @@ public async Task Issue922_ReconnectRaised() Assert.Equal(0, Volatile.Read(ref restoreCount)); var server = conn.GetServer(TestConfig.Current.PrimaryServerAndPort); + Assert.SkipUnless(server.CanSimulateConnectionFailure(), "Skipping because server cannot simulate connection failure"); server.SimulateConnectionFailure(SimulatedFailureType.All); await UntilConditionAsync(TimeSpan.FromSeconds(10), () => Volatile.Read(ref failCount) >= 2 && Volatile.Read(ref restoreCount) >= 2); diff --git a/tests/StackExchange.Redis.Tests/ConnectionFailedErrorsTests.cs b/tests/StackExchange.Redis.Tests/ConnectionFailedErrorsTests.cs index ce1a31980..58a1fba76 100644 --- a/tests/StackExchange.Redis.Tests/ConnectionFailedErrorsTests.cs +++ b/tests/StackExchange.Redis.Tests/ConnectionFailedErrorsTests.cs @@ -180,6 +180,7 @@ void InnerScenario() { conn.GetDatabase(); var server = conn.GetServer(conn.GetEndPoints()[0]); + Assert.SkipUnless(server.CanSimulateConnectionFailure(), "Skipping because server cannot simulate connection failure"); conn.AllowConnect = false; diff --git a/tests/StackExchange.Redis.Tests/ExceptionFactoryTests.cs b/tests/StackExchange.Redis.Tests/ExceptionFactoryTests.cs index 65d6946dc..77d92fbe2 100644 --- a/tests/StackExchange.Redis.Tests/ExceptionFactoryTests.cs +++ b/tests/StackExchange.Redis.Tests/ExceptionFactoryTests.cs @@ -4,7 +4,7 @@ namespace StackExchange.Redis.Tests; -public class ExceptionFactoryTests(ITestOutputHelper output) : TestBase(output) +public class ExceptionFactoryTests(ITestOutputHelper output, InProcServerFixture fixture) : TestBase(output, fixture) { [Fact] public async Task NullLastException() @@ -21,7 +21,7 @@ public async Task NullLastException() public void CanGetVersion() { var libVer = Utils.GetLibVersion(); - Assert.Matches(@"2\.[0-9]+\.[0-9]+(\.[0-9]+)?", libVer); + Assert.Matches(@"[2-3]\.[0-9]+\.[0-9]+(\.[0-9]+)?", libVer); } #if DEBUG @@ -37,7 +37,9 @@ public async Task MultipleEndpointsThrowConnectionException() foreach (var endpoint in conn.GetEndPoints()) { - conn.GetServer(endpoint).SimulateConnectionFailure(SimulatedFailureType.All); + var server = conn.GetServer(endpoint); + Assert.SkipUnless(server.CanSimulateConnectionFailure(), "Skipping because server cannot simulate connection failure"); + server.SimulateConnectionFailure(SimulatedFailureType.All); } var ex = ExceptionFactory.NoConnectionAvailable(conn.UnderlyingMultiplexer, null, null); @@ -64,7 +66,9 @@ public async Task ServerTakesPrecendenceOverSnapshot() conn.GetDatabase(); conn.AllowConnect = false; - conn.GetServer(conn.GetEndPoints()[0]).SimulateConnectionFailure(SimulatedFailureType.All); + var server = conn.GetServer(conn.GetEndPoints()[0]); + Assert.SkipUnless(server.CanSimulateConnectionFailure(), "Skipping because server cannot simulate connection failure"); + server.SimulateConnectionFailure(SimulatedFailureType.All); var ex = ExceptionFactory.NoConnectionAvailable(conn.UnderlyingMultiplexer, null, conn.GetServerSnapshot()[0]); Assert.IsType(ex); diff --git a/tests/StackExchange.Redis.Tests/FSharpCompatTests.cs b/tests/StackExchange.Redis.Tests/FSharpCompatTests.cs index 192234f2d..bde8c0fcf 100644 --- a/tests/StackExchange.Redis.Tests/FSharpCompatTests.cs +++ b/tests/StackExchange.Redis.Tests/FSharpCompatTests.cs @@ -18,7 +18,7 @@ public void RedisValueConstructor() { Assert.Equal(default, new RedisValue()); Assert.Equal((RedisValue)"MyKey", new RedisValue("MyKey")); - Assert.Equal((RedisValue)"MyKey2", new RedisValue("MyKey2", 0)); + Assert.Equal((RedisValue)"MyKey2", new RedisValue("MyKey2")); } #pragma warning restore SA1129 // Do not use default value type constructor } diff --git a/tests/StackExchange.Redis.Tests/FailoverTests.cs b/tests/StackExchange.Redis.Tests/FailoverTests.cs index 9c330a3f3..c309c96cc 100644 --- a/tests/StackExchange.Redis.Tests/FailoverTests.cs +++ b/tests/StackExchange.Redis.Tests/FailoverTests.cs @@ -222,6 +222,7 @@ public async Task SubscriptionsSurviveConnectionFailureAsync() Assert.Equal(1, counter1); var server = GetServer(conn); + Assert.SkipUnless(server.CanSimulateConnectionFailure(), "Skipping because server cannot simulate connection failure"); var socketCount = server.GetCounters().Subscription.SocketCount; Log($"Expecting 1 socket, got {socketCount}"); Assert.Equal(1, socketCount); diff --git a/tests/StackExchange.Redis.Tests/GlobalUsings.cs b/tests/StackExchange.Redis.Tests/GlobalUsings.cs deleted file mode 100644 index ca9c34d74..000000000 --- a/tests/StackExchange.Redis.Tests/GlobalUsings.cs +++ /dev/null @@ -1,3 +0,0 @@ -extern alias respite; -global using AsciiHash = respite::RESPite.AsciiHash; -global using AsciiHashAttribute = respite::RESPite.AsciiHashAttribute; diff --git a/tests/StackExchange.Redis.Tests/Helpers/SharedConnectionFixture.cs b/tests/StackExchange.Redis.Tests/Helpers/SharedConnectionFixture.cs index 9656ee45b..8da84bce6 100644 --- a/tests/StackExchange.Redis.Tests/Helpers/SharedConnectionFixture.cs +++ b/tests/StackExchange.Redis.Tests/Helpers/SharedConnectionFixture.cs @@ -17,23 +17,8 @@ namespace StackExchange.Redis.Tests; public class SharedConnectionFixture : IDisposable { - public bool IsEnabled { get; } - - private readonly ConnectionMultiplexer _actualConnection; - public string Configuration { get; } - - public SharedConnectionFixture() - { - IsEnabled = TestConfig.Current.UseSharedConnection; - Configuration = TestBase.GetDefaultConfiguration(); - _actualConnection = TestBase.CreateDefault( - output: null, - clientName: nameof(SharedConnectionFixture), - configuration: Configuration, - allowAdmin: true); - _actualConnection.InternalError += OnInternalError; - _actualConnection.ConnectionFailed += OnConnectionFailed; - } + public bool IsEnabled { get; } = TestConfig.Current.UseSharedConnection; + public string Configuration { get; } = TestBase.GetDefaultConfiguration(); private NonDisposingConnection? resp2, resp3; internal IInternalConnectionMultiplexer GetConnection(TestBase obj, RedisProtocol protocol, [CallerMemberName] string caller = "") @@ -234,8 +219,8 @@ public void ExportConfiguration(Stream destination, ExportOptions options = Expo public void Dispose() { - resp2?.UnderlyingConnection?.Dispose(); - resp3?.UnderlyingConnection?.Dispose(); + try { resp2?.UnderlyingConnection?.Dispose(); } catch { } + try { resp3?.UnderlyingConnection?.Dispose(); } catch { } GC.SuppressFinalize(this); } @@ -273,11 +258,16 @@ public void Teardown(TextWriter output) } // Assert.True(false, $"There were {privateFailCount} private ambient exceptions."); } + TearDown(resp2, output); + TearDown(resp3, output); + } - if (_actualConnection != null) + private void TearDown(IInternalConnectionMultiplexer? connection, TextWriter output) + { + if (connection is { } conn) { - TestBase.Log(output, "Connection Counts: " + _actualConnection.GetCounters().ToString()); - foreach (var ep in _actualConnection.GetServerSnapshot()) + TestBase.Log(output, "Connection Counts: " + conn.GetCounters().ToString()); + foreach (var ep in conn.GetServerSnapshot()) { var interactive = ep.GetBridge(ConnectionType.Interactive); TestBase.Log(output, $" {Format.ToString(interactive)}: {interactive?.GetStatus()}"); diff --git a/tests/StackExchange.Redis.Tests/InProcessTestServer.cs b/tests/StackExchange.Redis.Tests/InProcessTestServer.cs index 6f80215dd..0a679d514 100644 --- a/tests/StackExchange.Redis.Tests/InProcessTestServer.cs +++ b/tests/StackExchange.Redis.Tests/InProcessTestServer.cs @@ -1,5 +1,4 @@ -extern alias respite; -using System; +using System; using System.IO; using System.IO.Pipelines; using System.Net; @@ -7,7 +6,6 @@ using System.Text; using System.Threading; using System.Threading.Tasks; -using respite::RESPite.Messages; using StackExchange.Redis.Configuration; using StackExchange.Redis.Server; using Xunit; @@ -26,8 +24,8 @@ public InProcessTestServer(ITestOutputHelper? log = null) Tunnel = new InProcTunnel(this); } - public Task ConnectAsync(bool withPubSub = true /*, WriteMode writeMode = WriteMode.Default */, TextWriter? log = null) - => ConnectionMultiplexer.ConnectAsync(GetClientConfig(withPubSub /*, writeMode */), log); + public Task ConnectAsync(bool withPubSub = true, WriteMode writeMode = WriteMode.Default, TextWriter? log = null) + => ConnectionMultiplexer.ConnectAsync(GetClientConfig(withPubSub, writeMode), log); // view request/response highlights in the log public override TypedRedisValue Execute(RedisClient client, in RedisRequest request) @@ -59,7 +57,7 @@ public override TypedRedisValue Execute(RedisClient client, in RedisRequest requ return result; } - public ConfigurationOptions GetClientConfig(bool withPubSub = true /*, WriteMode writeMode = WriteMode.Default */) + public ConfigurationOptions GetClientConfig(bool withPubSub = true, WriteMode writeMode = WriteMode.Default) { var commands = GetCommands(); if (!withPubSub) @@ -85,6 +83,7 @@ public ConfigurationOptions GetClientConfig(bool withPubSub = true /*, WriteMode AsyncTimeout = 5000, AllowAdmin = true, Tunnel = Tunnel, + WriteMode = (BufferedStreamWriter.WriteMode)writeMode, Protocol = TestContext.Current.GetProtocol(), // WriteMode = (BufferedStreamWriter.WriteMode)writeMode, }; diff --git a/tests/StackExchange.Redis.Tests/Issues/Issue1103Tests.cs b/tests/StackExchange.Redis.Tests/Issues/Issue1103Tests.cs index ab4042042..fb7fbfb5e 100644 --- a/tests/StackExchange.Redis.Tests/Issues/Issue1103Tests.cs +++ b/tests/StackExchange.Redis.Tests/Issues/Issue1103Tests.cs @@ -21,6 +21,7 @@ public async Task LargeUInt64StoredCorrectly(ulong value, int storageType) RedisKey key = Me(); var db = conn.GetDatabase(); RedisValue typed = value; + Assert.Equal(value.ToString(), typed.ToString()); // only need UInt64 for 64-bits Assert.Equal((StorageType)storageType, typed.Type); @@ -28,7 +29,7 @@ public async Task LargeUInt64StoredCorrectly(ulong value, int storageType) var fromRedis = db.StringGet(key); Log($"{fromRedis.Type}: {fromRedis}"); - Assert.Equal(StorageType.Raw, fromRedis.Type); + Assert.Equal(StorageType.ByteArray, fromRedis.Type); Assert.Equal(value, (ulong)fromRedis); Assert.Equal(value.ToString(CultureInfo.InvariantCulture), fromRedis.ToString()); diff --git a/tests/StackExchange.Redis.Tests/Issues/Issue2392Tests.cs b/tests/StackExchange.Redis.Tests/Issues/Issue2392Tests.cs index 39df94021..7425bd85e 100644 --- a/tests/StackExchange.Redis.Tests/Issues/Issue2392Tests.cs +++ b/tests/StackExchange.Redis.Tests/Issues/Issue2392Tests.cs @@ -29,6 +29,7 @@ public async Task Execute() var key = Me(); var db = conn.GetDatabase(); var server = conn.GetServerSnapshot()[0]; + Assert.SkipUnless(server.CanSimulateConnectionFailure, "Skipping because server cannot simulate connection failure"); // Fail the connection conn.AllowConnect = false; diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs similarity index 99% rename from tests/StackExchange.Redis.Tests/KeyNotificationTests.cs rename to tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs index 0a70aa739..e6e6c83b9 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs @@ -6,7 +6,7 @@ namespace StackExchange.Redis.Tests; -public class KeyNotificationTests(ITestOutputHelper log) +public class KeyNotificationUnitTests(ITestOutputHelper log) { [Theory] [InlineData("foo", "foo")] @@ -464,7 +464,7 @@ public void DefaultKeyNotification_HasExpectedProperties() [InlineData("new", KeyNotificationType.New)] [InlineData("overwritten", KeyNotificationType.Overwritten)] [InlineData("type_changed", KeyNotificationType.TypeChanged)] - public unsafe void FastHashParse_AllKnownValues_ParseCorrectly(string raw, KeyNotificationType parsed) + public unsafe void AsciiHashParse_AllKnownValues_ParseCorrectly(string raw, KeyNotificationType parsed) { var arr = ArrayPool.Shared.Rent(Encoding.UTF8.GetMaxByteCount(raw.Length)); int bytes; diff --git a/tests/StackExchange.Redis.Tests/LoggerTests.cs b/tests/StackExchange.Redis.Tests/LoggerTests.cs index 682856baa..90be10f74 100644 --- a/tests/StackExchange.Redis.Tests/LoggerTests.cs +++ b/tests/StackExchange.Redis.Tests/LoggerTests.cs @@ -15,19 +15,20 @@ public class LoggerTests(ITestOutputHelper output) : TestBase(output) [Fact] public async Task BasicLoggerConfig() { - var traceLogger = new TestLogger(LogLevel.Trace, Writer); - var debugLogger = new TestLogger(LogLevel.Debug, Writer); - var infoLogger = new TestLogger(LogLevel.Information, Writer); - var warningLogger = new TestLogger(LogLevel.Warning, Writer); - var errorLogger = new TestLogger(LogLevel.Error, Writer); - var criticalLogger = new TestLogger(LogLevel.Critical, Writer); + var traceLogger = new TestLogger(LogLevel.Trace, TextWriter.Null); + var debugLogger = new TestLogger(LogLevel.Debug, TextWriter.Null); + var infoLogger = new TestLogger(LogLevel.Information, TextWriter.Null); + var warningLogger = new TestLogger(LogLevel.Warning, TextWriter.Null); + var errorLogger = new TestLogger(LogLevel.Error, TextWriter.Null); + var criticalLogger = new TestLogger(LogLevel.Critical, TextWriter.Null); var options = ConfigurationOptions.Parse(GetConfiguration()); options.LoggerFactory = new TestWrapperLoggerFactory(new TestMultiLogger(traceLogger, debugLogger, infoLogger, warningLogger, errorLogger, criticalLogger)); await using var conn = await ConnectionMultiplexer.ConnectAsync(options); - // We expect more at the trace level: GET, ECHO, PING on commands - Assert.True(traceLogger.CallCount > debugLogger.CallCount); + Log($"Trace: {traceLogger.CallCount}, Debug: {debugLogger.CallCount}, Info: {infoLogger.CallCount}, Warning: {warningLogger.CallCount}, Error: {errorLogger.CallCount}"); + // We expect more (or at least: no less) at the trace level: GET, ECHO, PING on commands + Assert.True(traceLogger.CallCount >= debugLogger.CallCount); // Many calls for all log lines - don't set exact here since every addition would break the test Assert.True(debugLogger.CallCount > 30); Assert.True(infoLogger.CallCount > 30); @@ -37,11 +38,35 @@ public async Task BasicLoggerConfig() Assert.Equal(0, criticalLogger.CallCount); } + private sealed class EnabledNullLogger : ILogger, IDisposable + { + private EnabledNullLogger() { } + public static readonly ILogger Instance = new EnabledNullLogger(); + + public bool IsEnabled(LogLevel level) => true; // NullLogger now says "no", which breaks our counting +#if NET10_0_OR_GREATER + public IDisposable? BeginScope(TState state) where TState : notnull => this; +#else + public IDisposable BeginScope(TState state) => this; +#endif + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + // but do nothing with it + } + void IDisposable.Dispose() { } + } + [Fact] public async Task WrappedLogger() { var options = ConfigurationOptions.Parse(GetConfiguration()); - var wrapped = new TestWrapperLoggerFactory(NullLogger.Instance); + var wrapped = new TestWrapperLoggerFactory(EnabledNullLogger.Instance); options.LoggerFactory = wrapped; await using var conn = await ConnectionMultiplexer.ConnectAsync(options); diff --git a/tests/StackExchange.Redis.Tests/MovedUnitTests.cs b/tests/StackExchange.Redis.Tests/MovedUnitTests.cs index 5618adf27..9c8b3a330 100644 --- a/tests/StackExchange.Redis.Tests/MovedUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/MovedUnitTests.cs @@ -1,7 +1,5 @@ -using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Threading.Tasks; -using StackExchange.Redis.Configuration; using Xunit; namespace StackExchange.Redis.Tests; @@ -17,16 +15,20 @@ public class MovedUnitTests(ITestOutputHelper log) private RedisKey Me([CallerMemberName] string callerName = "") => callerName; [Theory] - [InlineData(ServerType.Cluster)] - [InlineData(ServerType.Standalone)] - public async Task CrossSlotDisallowed(ServerType serverType) + [InlineData(ServerType.Cluster, WriteMode.Sync)] + [InlineData(ServerType.Standalone, WriteMode.Sync)] + [InlineData(ServerType.Cluster, WriteMode.Async)] + [InlineData(ServerType.Standalone, WriteMode.Async)] + [InlineData(ServerType.Cluster, WriteMode.Pipe)] + [InlineData(ServerType.Standalone, WriteMode.Pipe)] + public async Task CrossSlotDisallowed(ServerType serverType, WriteMode writeMode) { // intentionally sending as strings (not keys) via execute to prevent the // client library from getting in our way string keyA = "abc", keyB = "def"; // known to be on different slots using var server = new InProcessTestServer(log) { ServerType = serverType }; - await using var muxer = await server.ConnectAsync(); + await using var muxer = await server.ConnectAsync(writeMode: writeMode, withPubSub: false); var db = muxer.GetDatabase(); await db.StringSetAsync(keyA, "value", flags: CommandFlags.FireAndForget); @@ -116,7 +118,7 @@ public async Task MovedToSameEndpoint_TriggersReconnectAndRetry_CommandSucceeds( log: log) { ServerType = serverType, }; // Act: Connect to the test server - await using var conn = await testServer.ConnectAsync(); + await using var conn = await testServer.ConnectAsync(withPubSub: false); // Ping the server to ensure it's responsive var server = conn.GetServer(testServer.DefaultEndPoint); diff --git a/tests/StackExchange.Redis.Tests/ParseTests.cs b/tests/StackExchange.Redis.Tests/ParseTests.cs index 2621ddab9..ac1e5bca8 100644 --- a/tests/StackExchange.Redis.Tests/ParseTests.cs +++ b/tests/StackExchange.Redis.Tests/ParseTests.cs @@ -1,8 +1,11 @@ using System; using System.Buffers; using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; using System.Text; -using Pipelines.Sockets.Unofficial.Arenas; +using System.Threading.Tasks; +using StackExchange.Redis.Configuration; using Xunit; namespace StackExchange.Redis.Tests; @@ -27,20 +30,17 @@ public static IEnumerable GetTestData() yield return new object[] { "$4\r\nPING\r\n$4\r\nPONG\r\n$4\r\nPONG\r\n$", 3 }; } - [Theory] + [Theory(Timeout = 1000)] [MemberData(nameof(GetTestData))] - public void ParseAsSingleChunk(string ascii, int expected) + public Task ParseAsSingleChunk(string ascii, int expected) { var buffer = new ReadOnlySequence(Encoding.ASCII.GetBytes(ascii)); - using (var arena = new Arena()) - { - ProcessMessages(arena, buffer, expected); - } + return ProcessMessagesAsync(buffer, expected); } - [Theory] + [Theory(Timeout = 1000)] [MemberData(nameof(GetTestData))] - public void ParseAsLotsOfChunks(string ascii, int expected) + public Task ParseAsLotsOfChunks(string ascii, int expected) { var bytes = Encoding.ASCII.GetBytes(ascii); FragmentedSegment? chain = null, tail = null; @@ -59,23 +59,47 @@ public void ParseAsLotsOfChunks(string ascii, int expected) } var buffer = new ReadOnlySequence(chain!, 0, tail!, 1); Assert.Equal(bytes.Length, buffer.Length); - using (var arena = new Arena()) - { - ProcessMessages(arena, buffer, expected); - } + return ProcessMessagesAsync(buffer, expected); } - private void ProcessMessages(Arena arena, ReadOnlySequence buffer, int expected) + private async Task ProcessMessagesAsync(ReadOnlySequence buffer, int expected, bool isInbound = false) { + var cancel = TestContext.Current.CancellationToken; Log($"chain: {buffer.Length}"); - var reader = new BufferReader(buffer); - RawResult result; + MemoryStream ms; + if (buffer.IsSingleSegment && MemoryMarshal.TryGetArray(buffer.First, out var segment)) + { + // use existing buffer + ms = new MemoryStream(segment.Array!, segment.Offset, (int)buffer.Length, false, true); + } + else + { + ms = new MemoryStream(checked((int)buffer.Length)); + foreach (var chunk in buffer) + { +#if NETCOREAPP || NETSTANDARD2_1_OR_GREATER + ms.Write(chunk.Span); +#else + ms.Write(chunk); +#endif + } + + ms.Position = 0; + } + +#pragma warning disable CS0618 // Type or member is obsolete + var reader = new LoggingTunnel.StreamRespReader(ms, isInbound: isInbound); +#pragma warning restore CS0618 // Type or member is obsolete int found = 0; - while (!(result = PhysicalConnection.TryParseResult(false, arena, buffer, ref reader, false, null, false)).IsNull) + while (!cancel.IsCancellationRequested) { - Log($"{result} - {result.GetString()}"); + var oldPos = reader.Position; + var result = await reader.ReadOneAsync(cancel).ForAwait(); + if (result.Result is null) break; + Log($"[{oldPos},{reader.Position}): {result} - {result.Result}"); found++; } + cancel.ThrowIfCancellationRequested(); Assert.Equal(expected, found); } diff --git a/tests/StackExchange.Redis.Tests/ProfilingTests.cs b/tests/StackExchange.Redis.Tests/ProfilingTests.cs index 366abd395..2ce18684d 100644 --- a/tests/StackExchange.Redis.Tests/ProfilingTests.cs +++ b/tests/StackExchange.Redis.Tests/ProfilingTests.cs @@ -78,7 +78,7 @@ public async Task Simple() AssertProfiledCommandValues(eval2, conn, dbId); - AssertProfiledCommandValues(echo, conn, dbId); + AssertProfiledCommandValues(echo, conn, -1); // we recognize ECHO as db-free } private static void AssertProfiledCommandValues(IProfiledCommand command, IConnectionMultiplexer conn, int dbId) diff --git a/tests/StackExchange.Redis.Tests/RawResultTests.cs b/tests/StackExchange.Redis.Tests/RawResultTests.cs deleted file mode 100644 index 9cf578ee1..000000000 --- a/tests/StackExchange.Redis.Tests/RawResultTests.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.Buffers; -using Xunit; - -namespace StackExchange.Redis.Tests; - -public class RawResultTests -{ - [Fact] - public void TypeLoads() - { - var type = typeof(RawResult); - Assert.Equal(nameof(RawResult), type.Name); - } - - [Theory] - [InlineData(ResultType.BulkString)] - [InlineData(ResultType.Null)] - public void NullWorks(ResultType type) - { - var result = new RawResult(type, ReadOnlySequence.Empty, RawResult.ResultFlags.None); - Assert.Equal(type, result.Resp3Type); - Assert.True(result.HasValue); - Assert.True(result.IsNull); - - var value = result.AsRedisValue(); - - Assert.True(value.IsNull); - string? s = value; - Assert.Null(s); - - byte[]? arr = (byte[]?)value; - Assert.Null(arr); - } - - [Fact] - public void DefaultWorks() - { - var result = RawResult.Nil; - Assert.Equal(ResultType.None, result.Resp3Type); - Assert.False(result.HasValue); - Assert.True(result.IsNull); - - var value = result.AsRedisValue(); - - Assert.True(value.IsNull); - var s = (string?)value; - Assert.Null(s); - - var arr = (byte[]?)value; - Assert.Null(arr); - } - - [Fact] - public void NilWorks() - { - var result = RawResult.Nil; - Assert.Equal(ResultType.None, result.Resp3Type); - Assert.True(result.IsNull); - - var value = result.AsRedisValue(); - - Assert.True(value.IsNull); - var s = (string?)value; - Assert.Null(s); - - var arr = (byte[]?)value; - Assert.Null(arr); - } -} diff --git a/tests/StackExchange.Redis.Tests/RedisValueEquivalencyTests.cs b/tests/StackExchange.Redis.Tests/RedisValueEquivalencyTests.cs index 391a0237a..e68448b54 100644 --- a/tests/StackExchange.Redis.Tests/RedisValueEquivalencyTests.cs +++ b/tests/StackExchange.Redis.Tests/RedisValueEquivalencyTests.cs @@ -5,7 +5,7 @@ namespace StackExchange.Redis.Tests; -public class RedisValueEquivalency +public class RedisValueEquivalencyUnitTests { // internal storage types: null, integer, double, string, raw // public perceived types: int, long, double, bool, memory / byte[] @@ -441,7 +441,7 @@ public void RedisValueLengthUInt64() public void RedisValueLengthRaw() { RedisValue value = new byte[] { 0, 1, 2 }; - Assert.Equal(RedisValue.StorageType.Raw, value.Type); + Assert.Equal(RedisValue.StorageType.ByteArray, value.Type); Assert.Equal(3, value.Length()); } diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/AutoConfigure.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/AutoConfigure.cs new file mode 100644 index 000000000..f8b0daa0d --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/AutoConfigure.cs @@ -0,0 +1,130 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class AutoConfigure(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Fact] + public void ClientId_Integer_Success() + { + // CLIENT ID response + var resp = ":11\r\n"; + var message = Message.Create(-1, default, RedisCommand.CLIENT); + + // Note: This will return false because we don't have a real connection with a server endpoint + // The processor will throw because it can't set the connection ID without a real connection + var success = TryExecute(resp, ResultProcessor.AutoConfigure, out bool result, out var exception, message); + + Assert.False(success); + Assert.NotNull(exception); + Assert.IsType(exception); + } + + [Fact] + public void Info_BulkString_Success() + { + // INFO response with replication info + var info = "# Replication\r\n" + + "role:master\r\n" + + "connected_slaves:0\r\n" + + "master_failover_state:no-failover\r\n" + + "master_replid:8c3e3c3e3c3e3c3e3c3e3c3e3c3e3c3e3c3e3c3e\r\n" + + "master_replid2:0000000000000000000000000000000000000000\r\n" + + "master_repl_offset:0\r\n" + + "second_repl_offset:-1\r\n" + + "repl_backlog_active:0\r\n" + + "repl_backlog_size:1048576\r\n" + + "repl_backlog_first_byte_offset:0\r\n" + + "repl_backlog_histlen:0\r\n"; + + var resp = $"${info.Length}\r\n{info}\r\n"; + var message = Message.Create(-1, default, RedisCommand.INFO); + + // Note: This will return false because we don't have a real connection with a server endpoint + var success = TryExecute(resp, ResultProcessor.AutoConfigure, out bool result, out var exception, message); + + Assert.False(success); + Assert.NotNull(exception); + Assert.IsType(exception); + } + + [Fact] + public void Info_WithVersion_Success() + { + // INFO response with version info + var info = "# Server\r\n" + + "redis_version:7.2.4\r\n" + + "redis_git_sha1:00000000\r\n" + + "redis_mode:standalone\r\n" + + "os:Linux 5.15.0-1-amd64 x86_64\r\n" + + "arch_bits:64\r\n"; + + var resp = $"${info.Length}\r\n{info}\r\n"; + var message = Message.Create(-1, default, RedisCommand.INFO); + + var success = TryExecute(resp, ResultProcessor.AutoConfigure, out bool result, out var exception, message); + + Assert.False(success); + Assert.NotNull(exception); + Assert.IsType(exception); + } + + [Fact] + public void Info_EmptyString_Success() + { + // Empty INFO response + var resp = "$0\r\n\r\n"; + var message = Message.Create(-1, default, RedisCommand.INFO); + + var success = TryExecute(resp, ResultProcessor.AutoConfigure, out bool result, out var exception, message); + + Assert.False(success); + Assert.NotNull(exception); + Assert.IsType(exception); + } + + [Fact] + public void Info_Null_Success() + { + // Null INFO response + var resp = "$-1\r\n"; + var message = Message.Create(-1, default, RedisCommand.INFO); + + var success = TryExecute(resp, ResultProcessor.AutoConfigure, out bool result, out var exception, message); + + Assert.False(success); + Assert.NotNull(exception); + Assert.IsType(exception); + } + + [Fact] + public void Config_Array_Success() + { + // CONFIG GET timeout response + var resp = "*2\r\n" + + "$7\r\ntimeout\r\n" + + "$3\r\n300\r\n"; + var message = Message.Create(-1, default, RedisCommand.CONFIG); + + var success = TryExecute(resp, ResultProcessor.AutoConfigure, out bool result, out var exception, message); + + Assert.False(success); + Assert.NotNull(exception); + Assert.IsType(exception); + } + + [Fact] + public void ReadonlyError_Success() + { + // READONLY error response + var resp = "-READONLY You can't write against a read only replica.\r\n"; + var message = DummyMessage(); + + var success = TryExecute(resp, ResultProcessor.AutoConfigure, out bool result, out var exception, message); + + // Should handle the error - returns RedisServerException for error responses + Assert.False(success); + Assert.NotNull(exception); + Assert.IsType(exception); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/BasicAggregates.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/BasicAggregates.cs new file mode 100644 index 000000000..1ad1886f2 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/BasicAggregates.cs @@ -0,0 +1,229 @@ +using System.Linq; +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +/// +/// Tests for basic aggregate result processors (arrays) +/// +public class BasicAggregates(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Theory] + [InlineData("*3\r\n:1\r\n:2\r\n:3\r\n", "1,2,3")] + [InlineData("*?\r\n:1\r\n:2\r\n:3\r\n.\r\n", "1,2,3")] // streaming aggregate + [InlineData("*2\r\n,42\r\n,-99\r\n", "42,-99")] + [InlineData("*0\r\n", "")] + [InlineData("*?\r\n.\r\n", "")] // streaming empty aggregate + [InlineData("*-1\r\n", null)] + [InlineData("_\r\n", null)] + [InlineData(ATTRIB_FOO_BAR + "*2\r\n:10\r\n:20\r\n", "10,20")] + public void Int64Array(string resp, string? expected) => Assert.Equal(expected, Join(Execute(resp, ResultProcessor.Int64Array))); + + [Theory] + [InlineData("*3\r\n$3\r\nfoo\r\n$3\r\nbar\r\n$3\r\nbaz\r\n", "foo,bar,baz")] + [InlineData("*?\r\n$3\r\nfoo\r\n$3\r\nbar\r\n$3\r\nbaz\r\n.\r\n", "foo,bar,baz")] // streaming aggregate + [InlineData("*2\r\n+hello\r\n+world\r\n", "hello,world")] + [InlineData("*0\r\n", "")] + [InlineData("*?\r\n.\r\n", "")] // streaming empty aggregate + [InlineData("*-1\r\n", null)] + [InlineData("_\r\n", null)] + [InlineData(ATTRIB_FOO_BAR + "*2\r\n$1\r\na\r\n$1\r\nb\r\n", "a,b")] + public void StringArray(string resp, string? expected) => Assert.Equal(expected, Join(Execute(resp, ResultProcessor.StringArray))); + + [Theory] + [InlineData("*3\r\n:1\r\n:0\r\n:1\r\n", "True,False,True")] + [InlineData("*?\r\n:1\r\n:0\r\n:1\r\n.\r\n", "True,False,True")] // streaming aggregate + [InlineData("*2\r\n#t\r\n#f\r\n", "True,False")] + [InlineData("*0\r\n", "")] + [InlineData("*?\r\n.\r\n", "")] // streaming empty aggregate + [InlineData("*-1\r\n", null)] + [InlineData("_\r\n", null)] + [InlineData(ATTRIB_FOO_BAR + "*2\r\n:1\r\n:0\r\n", "True,False")] + public void BooleanArray(string resp, string? expected) => Assert.Equal(expected, Join(Execute(resp, ResultProcessor.BooleanArray))); + + [Theory] + [InlineData("*3\r\n$3\r\nfoo\r\n$3\r\nbar\r\n$3\r\nbaz\r\n", "foo,bar,baz")] + [InlineData("*?\r\n$3\r\nfoo\r\n$3\r\nbar\r\n$3\r\nbaz\r\n.\r\n", "foo,bar,baz")] // streaming aggregate + [InlineData("*3\r\n$3\r\nfoo\r\n$-1\r\n$3\r\nbaz\r\n", "foo,,baz")] // null element in middle (RESP2) + [InlineData("*3\r\n$3\r\nfoo\r\n_\r\n$3\r\nbaz\r\n", "foo,,baz")] // null element in middle (RESP3) + [InlineData("*0\r\n", "")] + [InlineData("*?\r\n.\r\n", "")] // streaming empty aggregate + [InlineData("$3\r\nfoo\r\n", "foo")] // single bulk string treated as array + [InlineData("$-1\r\n", "")] // null bulk string treated as empty array + [InlineData(ATTRIB_FOO_BAR + "*2\r\n$1\r\na\r\n:1\r\n", "a,1")] + public void RedisValueArray(string resp, string expected) => Assert.Equal(expected, Join(Execute(resp, ResultProcessor.RedisValueArray))); + + [Theory] + [InlineData("*3\r\n$3\r\nfoo\r\n$-1\r\n$3\r\nbaz\r\n", "foo,,baz")] // null element in middle (RESP2) + [InlineData("*3\r\n$3\r\nfoo\r\n_\r\n$3\r\nbaz\r\n", "foo,,baz")] // null element in middle (RESP3) + [InlineData("*2\r\n$5\r\nhello\r\n$5\r\nworld\r\n", "hello,world")] + [InlineData("*?\r\n$5\r\nhello\r\n$5\r\nworld\r\n.\r\n", "hello,world")] // streaming aggregate + [InlineData("*0\r\n", "")] + [InlineData("*?\r\n.\r\n", "")] // streaming empty aggregate + [InlineData("*-1\r\n", null)] + [InlineData("_\r\n", null)] + [InlineData(ATTRIB_FOO_BAR + "*2\r\n$1\r\na\r\n$1\r\nb\r\n", "a,b")] + public void NullableStringArray(string resp, string? expected) => Assert.Equal(expected, Join(Execute(resp, ResultProcessor.NullableStringArray))); + + [Theory] + [InlineData("*3\r\n$3\r\nkey\r\n$4\r\nkey2\r\n$4\r\nkey3\r\n", "key,key2,key3")] + [InlineData("*?\r\n$3\r\nkey\r\n$4\r\nkey2\r\n$4\r\nkey3\r\n.\r\n", "key,key2,key3")] // streaming aggregate + [InlineData("*3\r\n$3\r\nkey\r\n$-1\r\n$4\r\nkey3\r\n", "key,(null),key3")] // null element in middle (RESP2) + [InlineData("*3\r\n$3\r\nkey\r\n_\r\n$4\r\nkey3\r\n", "key,(null),key3")] // null element in middle (RESP3) + [InlineData("*0\r\n", "")] + [InlineData("*?\r\n.\r\n", "")] // streaming empty aggregate + [InlineData("*-1\r\n", null)] + [InlineData("_\r\n", null)] + [InlineData(ATTRIB_FOO_BAR + "*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n", "foo,bar")] + public void RedisKeyArray(string resp, string? expected) => Assert.Equal(expected, Join(Execute(resp, ResultProcessor.RedisKeyArray))); + + [Theory] + [InlineData("*2\r\n+foo\r\n:42\r\n", 42)] + [InlineData($"{ATTRIB_FOO_BAR}*2\r\n+foo\r\n:42\r\n", 42)] + [InlineData($"*2\r\n{ATTRIB_FOO_BAR}+foo\r\n:42\r\n", 42)] + [InlineData($"*2\r\n+foo\r\n{ATTRIB_FOO_BAR}:42\r\n", 42)] + public void PubSubNumSub(string resp, long expected) => Assert.Equal(expected, Execute(resp, ResultProcessor.PubSubNumSub)); + + [Theory] + [InlineData("*-1\r\n")] + [InlineData("_\r\n")] + [InlineData(":42\r\n")] + [InlineData("$-1\r\n")] + [InlineData("*3\r\n+foo\r\n:42\r\n+bar\r\n")] + [InlineData("*4\r\n+foo\r\n:42\r\n+bar\r\n:6\r\n")] + public void FailingPubSubNumSub(string resp) => ExecuteUnexpected(resp, ResultProcessor.PubSubNumSub); + + [Theory] + [InlineData("*3\r\n,1.5\r\n,2.5\r\n,3.5\r\n", "1.5,2.5,3.5")] + [InlineData("*?\r\n,1.5\r\n,2.5\r\n,3.5\r\n.\r\n", "1.5,2.5,3.5")] // streaming aggregate + [InlineData("*3\r\n,1.5\r\n_\r\n,3.5\r\n", "1.5,,3.5")] // null element in middle (RESP3) + [InlineData("*3\r\n,1.5\r\n$-1\r\n,3.5\r\n", "1.5,,3.5")] // null element in middle (RESP2) + [InlineData("*0\r\n", "")] + [InlineData("*?\r\n.\r\n", "")] // streaming empty aggregate + [InlineData("*-1\r\n", null)] + [InlineData("_\r\n", null)] + [InlineData(ATTRIB_FOO_BAR + "*2\r\n,1.1\r\n,2.2\r\n", "1.1,2.2")] + public void NullableDoubleArray(string resp, string? expected) => Assert.Equal(expected, Join(Execute(resp, ResultProcessor.NullableDoubleArray))); + + [Theory] + [InlineData("*3\r\n:0\r\n:1\r\n:2\r\n", "ConditionNotMet,Success,Due")] + [InlineData("*?\r\n:0\r\n:1\r\n:2\r\n.\r\n", "ConditionNotMet,Success,Due")] // streaming aggregate + [InlineData("*2\r\n:1\r\n:2\r\n", "Success,Due")] + [InlineData("*0\r\n", "")] + [InlineData("*?\r\n.\r\n", "")] // streaming empty aggregate + [InlineData("*-1\r\n", null)] + [InlineData("_\r\n", null)] + [InlineData(ATTRIB_FOO_BAR + "*2\r\n:0\r\n:2\r\n", "ConditionNotMet,Due")] + public void ExpireResultArray(string resp, string? expected) => Assert.Equal(expected, Join(Execute(resp, ResultProcessor.ExpireResultArray))); + + [Theory] + [InlineData("*3\r\n:1\r\n:-1\r\n:1\r\n", "Success,ConditionNotMet,Success")] + [InlineData("*?\r\n:1\r\n:-1\r\n:1\r\n.\r\n", "Success,ConditionNotMet,Success")] // streaming aggregate + [InlineData("*2\r\n:1\r\n:-1\r\n", "Success,ConditionNotMet")] + [InlineData("*0\r\n", "")] + [InlineData("*?\r\n.\r\n", "")] // streaming empty aggregate + [InlineData("*-1\r\n", null)] + [InlineData("_\r\n", null)] + [InlineData(ATTRIB_FOO_BAR + "*2\r\n:1\r\n:-1\r\n", "Success,ConditionNotMet")] + public void PersistResultArray(string resp, string? expected) => Assert.Equal(expected, Join(Execute(resp, ResultProcessor.PersistResultArray))); + + [Theory] + [InlineData("*2\r\n$3\r\nfoo\r\n,1.5\r\n", "foo: 1.5")] + [InlineData("*2\r\n$3\r\nbar\r\n,2.5\r\n", "bar: 2.5")] + [InlineData("*-1\r\n", null)] // RESP2 null array + [InlineData("_\r\n", null)] // RESP3 pure null + [InlineData("*0\r\n", null)] // empty array (0 elements) + [InlineData("*1\r\n$3\r\nfoo\r\n", null)] // array with 1 element + [InlineData("*3\r\n$3\r\nfoo\r\n,1.5\r\n$3\r\nbar\r\n", "foo: 1.5")] // array with 3 elements - takes first 2 + [InlineData("*?\r\n.\r\n", null)] // RESP3 streaming empty aggregate (0 elements) + [InlineData(ATTRIB_FOO_BAR + "*2\r\n$3\r\nbaz\r\n,3.5\r\n", "baz: 3.5")] + public void SortedSetEntry(string resp, string? expected) + { + var result = Execute(resp, ResultProcessor.SortedSetEntry); + if (expected == null) + { + Assert.Null(result); + } + else + { + Assert.NotNull(result); + Assert.Equal(expected, $"{result.Value.Element}: {result.Value.Score}"); + } + } + + [Theory] + [InlineData(":42\r\n")] + [InlineData("$3\r\nfoo\r\n")] + [InlineData("$-1\r\n")] // null scalar should NOT be treated as null result + public void FailingSortedSetEntry(string resp) => ExecuteUnexpected(resp, ResultProcessor.SortedSetEntry); + + [Theory] + [InlineData("*2\r\n$3\r\nkey\r\n*2\r\n*2\r\n$3\r\nfoo\r\n,1.5\r\n*2\r\n$3\r\nbar\r\n,2.5\r\n", "key", "foo: 1.5, bar: 2.5")] + [InlineData("*2\r\n$4\r\nkey2\r\n*1\r\n*2\r\n$3\r\nbaz\r\n,3.5\r\n", "key2", "baz: 3.5")] + [InlineData("*2\r\n$4\r\nkey3\r\n*0\r\n", "key3", "")] + [InlineData("*-1\r\n", null, null)] + [InlineData("_\r\n", null, null)] + [InlineData(ATTRIB_FOO_BAR + "*2\r\n$3\r\nkey\r\n*1\r\n*2\r\n$1\r\na\r\n,1.0\r\n", "key", "a: 1")] + public void SortedSetPopResult(string resp, string? key, string? values) + { + var result = Execute(resp, ResultProcessor.SortedSetPopResult); + if (key == null) + { + Assert.True(result.IsNull); + } + else + { + Assert.False(result.IsNull); + Assert.Equal(key, (string?)result.Key); + var entries = string.Join(", ", result.Entries.Select(e => $"{e.Element}: {e.Score}")); + Assert.Equal(values, entries); + } + } + + [Theory] + [InlineData(":42\r\n")] + [InlineData("$3\r\nfoo\r\n")] + [InlineData("*1\r\n$3\r\nkey\r\n")] + [InlineData("$-1\r\n")] // null scalar should NOT be treated as null result + public void FailingSortedSetPopResult(string resp) => ExecuteUnexpected(resp, ResultProcessor.SortedSetPopResult); + + [Theory] + [InlineData("*2\r\n$3\r\nkey\r\n*3\r\n$3\r\nfoo\r\n$3\r\nbar\r\n$3\r\nbaz\r\n", "key", "foo,bar,baz")] + [InlineData("*2\r\n$4\r\nkey2\r\n*1\r\n$1\r\na\r\n", "key2", "a")] + [InlineData("*2\r\n$4\r\nkey3\r\n*0\r\n", "key3", "")] + [InlineData("*-1\r\n", null, null)] + [InlineData("_\r\n", null, null)] + [InlineData(ATTRIB_FOO_BAR + "*2\r\n$3\r\nkey\r\n*2\r\n$1\r\nx\r\n$1\r\ny\r\n", "key", "x,y")] + public void ListPopResult(string resp, string? key, string? values) + { + var result = Execute(resp, ResultProcessor.ListPopResult); + if (key == null) + { + Assert.True(result.IsNull); + } + else + { + Assert.False(result.IsNull); + Assert.Equal(key, (string?)result.Key); + Assert.Equal(values, Join(result.Values)); + } + } + + [Theory] + [InlineData(":42\r\n")] + [InlineData("$3\r\nfoo\r\n")] + [InlineData("*1\r\n$3\r\nkey\r\n")] + [InlineData("$-1\r\n")] // null scalar should NOT be treated as null result + public void FailingListPopResult(string resp) => ExecuteUnexpected(resp, ResultProcessor.ListPopResult); + + [Theory] + [InlineData("*3\r\n$3\r\nfoo\r\n$3\r\nbar\r\n$3\r\nbaz\r\n", "foo,bar,baz")] + [InlineData("*?\r\n$3\r\nfoo\r\n$3\r\nbar\r\n$3\r\nbaz\r\n.\r\n", "foo,bar,baz")] // streaming aggregate + [InlineData("*2\r\n+hello\r\n+world\r\n", "hello,world")] + [InlineData("*0\r\n", "")] + [InlineData("*?\r\n.\r\n", "")] // streaming empty aggregate + [InlineData("*-1\r\n", null)] + [InlineData("_\r\n", null)] + [InlineData(ATTRIB_FOO_BAR + "*2\r\n$1\r\na\r\n$1\r\nb\r\n", "a,b")] + public void RedisChannelArray(string resp, string? expected) => Assert.Equal(expected, Join(Execute(resp, ResultProcessor.RedisChannelArrayLiteral))); +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/BasicScalars.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/BasicScalars.cs new file mode 100644 index 000000000..090b223f3 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/BasicScalars.cs @@ -0,0 +1,281 @@ +using System; +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +/// +/// Tests for basic scalar result processors (Int32, Int64, Double, Boolean, String, etc.) +/// +public class BasicScalars(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Theory] + [InlineData(":1\r\n", 1)] + [InlineData("+1\r\n", 1)] + [InlineData("$1\r\n1\r\n", 1)] + [InlineData("$?\r\n;1\r\n1\r\n;0\r\n", 1)] // streaming string + [InlineData(",1\r\n", 1)] + [InlineData(ATTRIB_FOO_BAR + ":1\r\n", 1)] + [InlineData(":-42\r\n", -42)] + [InlineData("+-42\r\n", -42)] + [InlineData("$3\r\n-42\r\n", -42)] + [InlineData("$?\r\n;1\r\n-\r\n;2\r\n42\r\n;0\r\n", -42)] // streaming string + [InlineData(",-42\r\n", -42)] + public void Int32(string resp, int value) => Assert.Equal(value, Execute(resp, ResultProcessor.Int32)); + + [Theory] + [InlineData("+OK\r\n")] + [InlineData("$4\r\nPONG\r\n")] + public void FailingInt32(string resp) => ExecuteUnexpected(resp, ResultProcessor.Int32); + + [Theory] + [InlineData(":1\r\n", 1)] + [InlineData("+1\r\n", 1)] + [InlineData("$1\r\n1\r\n", 1)] + [InlineData("$?\r\n;1\r\n1\r\n;0\r\n", 1)] // streaming string + [InlineData(",1\r\n", 1)] + [InlineData(ATTRIB_FOO_BAR + ":1\r\n", 1)] + [InlineData(":-42\r\n", -42)] + [InlineData("+-42\r\n", -42)] + [InlineData("$3\r\n-42\r\n", -42)] + [InlineData("$?\r\n;1\r\n-\r\n;2\r\n42\r\n;0\r\n", -42)] // streaming string + [InlineData(",-42\r\n", -42)] + public void Int64(string resp, long value) => Assert.Equal(value, Execute(resp, ResultProcessor.Int64)); + + [Theory] + [InlineData("+OK\r\n")] + [InlineData("$4\r\nPONG\r\n")] + public void FailingInt64(string resp) => ExecuteUnexpected(resp, ResultProcessor.Int64); + + [Theory] + [InlineData(":42\r\n", 42.0)] + [InlineData("+3.14\r\n", 3.14)] + [InlineData("$4\r\n3.14\r\n", 3.14)] + [InlineData("$?\r\n;1\r\n3\r\n;3\r\n.14\r\n;0\r\n", 3.14)] // streaming string + [InlineData(",3.14\r\n", 3.14)] + [InlineData(ATTRIB_FOO_BAR + ",3.14\r\n", 3.14)] + [InlineData(":-1\r\n", -1.0)] + [InlineData("+inf\r\n", double.PositiveInfinity)] + [InlineData(",inf\r\n", double.PositiveInfinity)] + [InlineData("$4\r\n-inf\r\n", double.NegativeInfinity)] + [InlineData("$?\r\n;2\r\n-i\r\n;2\r\nnf\r\n;0\r\n", double.NegativeInfinity)] // streaming string + [InlineData(",-inf\r\n", double.NegativeInfinity)] + [InlineData(",nan\r\n", double.NaN)] + public void Double(string resp, double value) => Assert.Equal(value, Execute(resp, ResultProcessor.Double)); + + [Theory] + [InlineData("_\r\n", null)] + [InlineData("$-1\r\n", null)] + [InlineData(":42\r\n", 42L)] + [InlineData("+42\r\n", 42L)] + [InlineData("$2\r\n42\r\n", 42L)] + [InlineData("$?\r\n;1\r\n4\r\n;1\r\n2\r\n;0\r\n", 42L)] // streaming string + [InlineData(",42\r\n", 42L)] + [InlineData(ATTRIB_FOO_BAR + ":42\r\n", 42L)] + public void NullableInt64(string resp, long? value) => Assert.Equal(value, Execute(resp, ResultProcessor.NullableInt64)); + + [Theory] + [InlineData("*1\r\n:99\r\n", 99L)] + [InlineData("*?\r\n:99\r\n.\r\n", 99L)] // streaming aggregate + [InlineData("*1\r\n$-1\r\n", null)] // unit array with RESP2 null bulk string + [InlineData("*1\r\n_\r\n", null)] // unit array with RESP3 null + [InlineData(ATTRIB_FOO_BAR + "*1\r\n:99\r\n", 99L)] + public void NullableInt64ArrayOfOne(string resp, long? value) => Assert.Equal(value, Execute(resp, ResultProcessor.NullableInt64)); + + [Theory] + [InlineData("*-1\r\n")] // null array + [InlineData("*0\r\n")] // empty array + [InlineData("*?\r\n.\r\n")] // streaming empty aggregate + [InlineData("*2\r\n:1\r\n:2\r\n")] // two elements + [InlineData("*?\r\n:1\r\n:2\r\n.\r\n")] // streaming aggregate with two elements + public void FailingNullableInt64ArrayOfNonOne(string resp) => ExecuteUnexpected(resp, ResultProcessor.NullableInt64); + + [Theory] + [InlineData("_\r\n", null)] + [InlineData("$-1\r\n", null)] + [InlineData(":42\r\n", 42.0)] + [InlineData("+3.14\r\n", 3.14)] + [InlineData("$4\r\n3.14\r\n", 3.14)] + [InlineData("$?\r\n;1\r\n3\r\n;3\r\n.14\r\n;0\r\n", 3.14)] // streaming string + [InlineData(",3.14\r\n", 3.14)] + [InlineData(ATTRIB_FOO_BAR + ",3.14\r\n", 3.14)] + public void NullableDouble(string resp, double? value) => Assert.Equal(value, Execute(resp, ResultProcessor.NullableDouble)); + + [Theory] + [InlineData("_\r\n", false)] // null = false + [InlineData(":0\r\n", false)] + [InlineData(":1\r\n", true)] + [InlineData("#f\r\n", false)] + [InlineData("#t\r\n", true)] + [InlineData("+OK\r\n", true)] + [InlineData(ATTRIB_FOO_BAR + ":1\r\n", true)] + public void Boolean(string resp, bool value) => Assert.Equal(value, Execute(resp, ResultProcessor.Boolean)); + + [Theory] + [InlineData("*1\r\n:1\r\n", true)] // SCRIPT EXISTS returns array + [InlineData("*?\r\n:1\r\n.\r\n", true)] // streaming aggregate + [InlineData("*1\r\n:0\r\n", false)] + [InlineData(ATTRIB_FOO_BAR + "*1\r\n:1\r\n", true)] + public void BooleanArrayOfOne(string resp, bool value) => Assert.Equal(value, Execute(resp, ResultProcessor.Boolean)); + + [Theory] + [InlineData("*0\r\n")] // empty array + [InlineData("*?\r\n.\r\n")] // streaming empty aggregate + [InlineData("*2\r\n:1\r\n:0\r\n")] // two elements + [InlineData("*?\r\n:1\r\n:0\r\n.\r\n")] // streaming aggregate with two elements + [InlineData("*1\r\n*1\r\n:1\r\n")] // nested array (not scalar) + public void FailingBooleanArrayOfNonOne(string resp) => ExecuteUnexpected(resp, ResultProcessor.Boolean); + + [Theory] + [InlineData("$5\r\nhello\r\n", "hello")] + [InlineData("$?\r\n;2\r\nhe\r\n;3\r\nllo\r\n;0\r\n", "hello")] // streaming string + [InlineData("$?\r\n;0\r\n", "")] // streaming empty string + [InlineData("+world\r\n", "world")] + [InlineData(":42\r\n", "42")] + [InlineData("$-1\r\n", null)] + [InlineData(ATTRIB_FOO_BAR + "$3\r\nfoo\r\n", "foo")] + public void String(string resp, string? value) => Assert.Equal(value, Execute(resp, ResultProcessor.String)); + + [Theory] + [InlineData("*1\r\n$3\r\nbar\r\n", "bar")] + [InlineData("*?\r\n$3\r\nbar\r\n.\r\n", "bar")] // streaming aggregate + [InlineData(ATTRIB_FOO_BAR + "*1\r\n$3\r\nbar\r\n", "bar")] + public void StringArrayOfOne(string resp, string? value) => Assert.Equal(value, Execute(resp, ResultProcessor.String)); + + [Theory] + [InlineData("*-1\r\n")] // null array + [InlineData("*0\r\n")] // empty array + [InlineData("*?\r\n.\r\n")] // streaming empty aggregate + [InlineData("*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n")] // two elements + [InlineData("*?\r\n$3\r\nfoo\r\n$3\r\nbar\r\n.\r\n")] // streaming aggregate with two elements + [InlineData("*1\r\n*1\r\n$3\r\nfoo\r\n")] // nested array (not scalar) + public void FailingStringArrayOfNonOne(string resp) => ExecuteUnexpected(resp, ResultProcessor.String); + + [Theory] + [InlineData("+string\r\n", Redis.RedisType.String)] + [InlineData("+hash\r\n", Redis.RedisType.Hash)] + [InlineData("+zset\r\n", Redis.RedisType.SortedSet)] + [InlineData("+set\r\n", Redis.RedisType.Set)] + [InlineData("+list\r\n", Redis.RedisType.List)] + [InlineData("+stream\r\n", Redis.RedisType.Stream)] + [InlineData("+blah\r\n", Redis.RedisType.Unknown)] + [InlineData("$-1\r\n", Redis.RedisType.None)] + [InlineData("_\r\n", Redis.RedisType.None)] + [InlineData("$0\r\n\r\n", Redis.RedisType.None)] + [InlineData(ATTRIB_FOO_BAR + "$6\r\nstring\r\n", Redis.RedisType.String)] + public void RedisType(string resp, RedisType value) => Assert.Equal(value, Execute(resp, ResultProcessor.RedisType)); + + [Theory] + [InlineData("$5\r\nhello\r\n", "hello")] + [InlineData("$?\r\n;2\r\nhe\r\n;3\r\nllo\r\n;0\r\n", "hello")] // streaming string + [InlineData("+world\r\n", "world")] + [InlineData(":42\r\n", "42")] + [InlineData("$-1\r\n", null)] + [InlineData("_\r\n", null)] + [InlineData(ATTRIB_FOO_BAR + "$3\r\nfoo\r\n", "foo")] + public void ByteArray(string resp, string? expected) + { + var result = Execute(resp, ResultProcessor.ByteArray); + if (expected is null) + { + Assert.Null(result); + } + else + { + Assert.Equal(expected, System.Text.Encoding.UTF8.GetString(result!)); + } + } + + [Theory] + [InlineData("*-1\r\n")] // null array + [InlineData("*0\r\n")] // empty array + [InlineData("*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n")] // array + public void FailingByteArray(string resp) => ExecuteUnexpected(resp, ResultProcessor.ByteArray); + + [Theory] + [InlineData("$5\r\nhello\r\n", "hello")] + [InlineData("$?\r\n;2\r\nhe\r\n;3\r\nllo\r\n;0\r\n", "hello")] // streaming string + [InlineData("+world\r\n", "world")] + [InlineData("$-1\r\n", null)] + [InlineData("_\r\n", null)] + [InlineData(ATTRIB_FOO_BAR + "$11\r\nclusterinfo\r\n", "clusterinfo")] + // note that this test does not include a valid cluster nodes response + public void ClusterNodesRaw(string resp, string? expected) => Assert.Equal(expected, Execute(resp, ResultProcessor.ClusterNodesRaw)); + + [Theory] + [InlineData("*0\r\n")] // empty array + [InlineData("*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n")] // array + public void FailingClusterNodesRaw(string resp) => ExecuteUnexpected(resp, ResultProcessor.ClusterNodesRaw); + + [Theory] + [InlineData(":42\r\n", 42L)] + [InlineData("+99\r\n", 99L)] + [InlineData("$2\r\n10\r\n", 10L)] + [InlineData("$?\r\n;1\r\n1\r\n;1\r\n0\r\n;0\r\n", 10L)] // streaming string + [InlineData(",123\r\n", 123L)] + [InlineData(ATTRIB_FOO_BAR + ":42\r\n", 42L)] + public void Int64DefaultValue(string resp, long expected) => Assert.Equal(expected, Execute(resp, Int64DefaultValue999)); + + [Theory] + [InlineData("_\r\n", 999L)] // null returns default + [InlineData("$-1\r\n", 999L)] // null returns default + public void Int64DefaultValueNull(string resp, long expected) => Assert.Equal(expected, Execute(resp, Int64DefaultValue999)); + + [Theory] + [InlineData("*0\r\n")] // empty array + [InlineData("*2\r\n:1\r\n:2\r\n")] // array + [InlineData("+notanumber\r\n")] // invalid number + public void FailingInt64DefaultValue(string resp) => ExecuteUnexpected(resp, Int64DefaultValue999); + + [Theory] + [InlineData("$5\r\nhello\r\n", "hello")] + [InlineData("$?\r\n;2\r\nhe\r\n;3\r\nllo\r\n;0\r\n", "hello")] // streaming string + [InlineData("+world\r\n", "world")] + [InlineData(":42\r\n", "42")] + [InlineData("$-1\r\n", "(null)")] + [InlineData("_\r\n", "(null)")] + [InlineData(ATTRIB_FOO_BAR + "$3\r\nfoo\r\n", "foo")] + public void RedisKey(string resp, string expected) + { + var result = Execute(resp, ResultProcessor.RedisKey); + Assert.Equal(expected, result.ToString()); + } + + [Theory] + [InlineData("*0\r\n")] // empty array + [InlineData("*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n")] // array + public void FailingRedisKey(string resp) => ExecuteUnexpected(resp, ResultProcessor.RedisKey); + + [Theory] + [InlineData("$5\r\nhello\r\n", "hello")] + [InlineData("$?\r\n;2\r\nhe\r\n;3\r\nllo\r\n;0\r\n", "hello")] // streaming string + [InlineData("+world\r\n", "world")] + [InlineData(":42\r\n", "42")] + [InlineData("$-1\r\n", "")] + [InlineData("_\r\n", "")] + [InlineData(",3.14\r\n", "3.14")] + [InlineData(ATTRIB_FOO_BAR + "$3\r\nfoo\r\n", "foo")] + public void RedisValue(string resp, string expected) + { + var result = Execute(resp, ResultProcessor.RedisValue); + Assert.Equal(expected, result.ToString()); + } + + [Theory] + [InlineData("*0\r\n")] // empty array + [InlineData("*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n")] // array + public void FailingRedisValue(string resp) => ExecuteUnexpected(resp, ResultProcessor.RedisValue); + + [Theory] + [InlineData("$5\r\nhello\r\n", "hello")] + [InlineData("$?\r\n;2\r\nhe\r\n;3\r\nllo\r\n;0\r\n", "hello")] // streaming string + [InlineData("+world\r\n", "world")] + [InlineData("$-1\r\n", null)] + [InlineData("_\r\n", null)] + [InlineData(ATTRIB_FOO_BAR + "$10\r\ntiebreaker\r\n", "tiebreaker")] + public void TieBreaker(string resp, string? expected) => Assert.Equal(expected, Execute(resp, ResultProcessor.TieBreaker)); + + [Theory] + [InlineData("*0\r\n")] // empty array + [InlineData("*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n")] // array + public void FailingTieBreaker(string resp) => ExecuteUnexpected(resp, ResultProcessor.TieBreaker); +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/ClientInfo.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/ClientInfo.cs new file mode 100644 index 000000000..086ef0566 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/ClientInfo.cs @@ -0,0 +1,93 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class ClientInfo(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Fact] + public void SingleClient_Success() + { + // CLIENT LIST returns a bulk string with newline-separated client information + var content = "id=86 addr=172.17.0.1:40750 laddr=172.17.0.2:3000 fd=22 name= age=7 idle=0 flags=N db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=26 qbuf-free=20448 argv-mem=10 multi-mem=0 rbs=1024 rbp=0 obl=0 oll=0 omem=0 tot-mem=22810 events=r cmd=client|list user=default redir=-1 resp=2 lib-name= lib-ver= io-thread=0 tot-net-in=48 tot-net-out=36 tot-cmds=0\n"; + var resp = $"${content.Length}\r\n{content}\r\n"; + + var result = Execute(resp, StackExchange.Redis.ClientInfo.Processor); + + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal(86, result[0].Id); + Assert.Equal("172.17.0.1:40750", result[0].Address?.ToString()); + Assert.Equal(7, result[0].AgeSeconds); + Assert.Equal(0, result[0].Database); + } + + [Fact] + public void MultipleClients_Success() + { + // Two clients (newline-separated, using \n not \r\n) + var line1 = "id=86 addr=172.17.0.1:40750 laddr=172.17.0.2:3000 fd=22 name= age=39 idle=32 flags=N db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=1024 rbp=0 obl=0 oll=0 omem=0 tot-mem=2304 events=r cmd=client|list user=default redir=-1 resp=2 lib-name= lib-ver= io-thread=0 tot-net-in=48 tot-net-out=390 tot-cmds=1\n"; + var line2 = "id=87 addr=172.17.0.1:60630 laddr=172.17.0.2:3000 fd=23 name= age=4 idle=0 flags=N db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=26 qbuf-free=20448 argv-mem=10 multi-mem=0 rbs=1024 rbp=7 obl=0 oll=0 omem=0 tot-mem=22810 events=r cmd=client|list user=default redir=-1 resp=2 lib-name= lib-ver= io-thread=0 tot-net-in=40 tot-net-out=7 tot-cmds=1\n"; + var content = line1 + line2; + var resp = $"${content.Length}\r\n{content}\r\n"; + + var result = Execute(resp, StackExchange.Redis.ClientInfo.Processor); + + Assert.NotNull(result); + Assert.Equal(2, result.Length); + Assert.Equal(86, result[0].Id); + Assert.Equal(87, result[1].Id); + Assert.Equal("172.17.0.1:40750", result[0].Address?.ToString()); + Assert.Equal("172.17.0.1:60630", result[1].Address?.ToString()); + } + + [Fact] + public void EmptyString_Success() + { + // Empty bulk string + var resp = "$0\r\n\r\n"; + + var result = Execute(resp, StackExchange.Redis.ClientInfo.Processor); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void NullBulkString_Failure() + { + // Null bulk string should fail + var resp = "$-1\r\n"; + + ExecuteUnexpected(resp, StackExchange.Redis.ClientInfo.Processor); + } + + [Fact] + public void NotBulkString_Failure() + { + // Simple string should fail + var resp = "+OK\r\n"; + + ExecuteUnexpected(resp, StackExchange.Redis.ClientInfo.Processor); + } + + [Fact] + public void VerbatimString_Success() + { + // Verbatim string with TXT encoding - should behave identically to bulk string + // Format: ={len}\r\n{enc}:{payload}\r\n where enc is exactly 3 bytes (e.g., "TXT") + var content = "id=86 addr=172.17.0.1:40750 laddr=172.17.0.2:3000 fd=22 name= age=7 idle=0 flags=N db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=26 qbuf-free=20448 argv-mem=10 multi-mem=0 rbs=1024 rbp=0 obl=0 oll=0 omem=0 tot-mem=22810 events=r cmd=client|list user=default redir=-1 resp=2 lib-name= lib-ver= io-thread=0 tot-net-in=48 tot-net-out=36 tot-cmds=0\n"; + var totalLen = 4 + content.Length; // "TXT:" + content + var resp = $"={totalLen}\r\nTXT:{content}\r\n"; + + var result = Execute(resp, StackExchange.Redis.ClientInfo.Processor); + + // ReadString() automatically strips the "TXT:" encoding prefix, + // so the result should be identical to the bulk string test + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal(86, result[0].Id); + Assert.Equal("172.17.0.1:40750", result[0].Address?.ToString()); + Assert.Equal(7, result[0].AgeSeconds); + Assert.Equal(0, result[0].Database); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/ClusterNodes.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/ClusterNodes.cs new file mode 100644 index 000000000..91e089c57 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/ClusterNodes.cs @@ -0,0 +1,12 @@ +using System.Linq; +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class ClusterNodes(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + // NOTE: ClusterNodesProcessor cannot be unit tested in isolation because it requires + // a real PhysicalConnection with a bridge to parse the cluster configuration. + // The processor calls connection.BridgeCouldBeNull which throws ObjectDisposedException + // in the test environment. These tests are covered by integration tests instead. +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/ConditionTests.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/ConditionTests.cs new file mode 100644 index 000000000..fbce6d4d5 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/ConditionTests.cs @@ -0,0 +1,408 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +/// +/// Unit tests for Condition subclasses using the RespReader path. +/// +public class ConditionTests(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + private static Message CreateConditionMessage(Condition condition, RedisCommand command, RedisKey key, params RedisValue[] values) + { + return values.Length switch + { + 0 => Condition.ConditionProcessor.CreateMessage(condition, 0, CommandFlags.None, command, key), + 1 => Condition.ConditionProcessor.CreateMessage(condition, 0, CommandFlags.None, command, key, values[0]), + 2 => Condition.ConditionProcessor.CreateMessage(condition, 0, CommandFlags.None, command, key, values[0], values[1]), + 5 => Condition.ConditionProcessor.CreateMessage(condition, 0, CommandFlags.None, command, key, values[0], values[1], values[2], values[3], values[4]), + _ => throw new System.NotSupportedException($"Unsupported value count: {values.Length}"), + }; + } + + [Fact] + public void ExistsCondition_KeyExists_True() + { + var condition = Condition.KeyExists("mykey"); + var message = CreateConditionMessage(condition, RedisCommand.EXISTS, "mykey"); + var result = Execute(":1\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void ExistsCondition_KeyExists_False() + { + var condition = Condition.KeyExists("mykey"); + var message = CreateConditionMessage(condition, RedisCommand.EXISTS, "mykey"); + var result = Execute(":0\r\n", Condition.ConditionProcessor.Default, message); + Assert.False(result); + } + + [Fact] + public void ExistsCondition_KeyNotExists_True() + { + var condition = Condition.KeyNotExists("mykey"); + var message = CreateConditionMessage(condition, RedisCommand.EXISTS, "mykey"); + var result = Execute(":0\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void ExistsCondition_KeyNotExists_False() + { + var condition = Condition.KeyNotExists("mykey"); + var message = CreateConditionMessage(condition, RedisCommand.EXISTS, "mykey"); + var result = Execute(":1\r\n", Condition.ConditionProcessor.Default, message); + Assert.False(result); + } + + [Fact] + public void ExistsCondition_HashExists_True() + { + var condition = Condition.HashExists("myhash", "field1"); + var message = CreateConditionMessage(condition, RedisCommand.HEXISTS, "myhash", "field1"); + var result = Execute(":1\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void ExistsCondition_HashNotExists_True() + { + var condition = Condition.HashNotExists("myhash", "field1"); + var message = CreateConditionMessage(condition, RedisCommand.HEXISTS, "myhash", "field1"); + var result = Execute(":0\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void ExistsCondition_SetContains_True() + { + var condition = Condition.SetContains("myset", "member1"); + var message = CreateConditionMessage(condition, RedisCommand.SISMEMBER, "myset", "member1"); + var result = Execute(":1\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void ExistsCondition_SetNotContains_True() + { + var condition = Condition.SetNotContains("myset", "member1"); + var message = CreateConditionMessage(condition, RedisCommand.SISMEMBER, "myset", "member1"); + var result = Execute(":0\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void ExistsCondition_SortedSetContains_True() + { + var condition = Condition.SortedSetContains("myzset", "member1"); + var message = CreateConditionMessage(condition, RedisCommand.ZSCORE, "myzset", "member1"); + var result = Execute("$1\r\n5\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void ExistsCondition_SortedSetContains_Null_False() + { + var condition = Condition.SortedSetContains("myzset", "member1"); + var message = CreateConditionMessage(condition, RedisCommand.ZSCORE, "myzset", "member1"); + var result = Execute("$-1\r\n", Condition.ConditionProcessor.Default, message); + Assert.False(result); + } + + [Fact] + public void ExistsCondition_SortedSetNotContains_True() + { + var condition = Condition.SortedSetNotContains("myzset", "member1"); + var message = CreateConditionMessage(condition, RedisCommand.ZSCORE, "myzset", "member1"); + var result = Execute("$-1\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void StartsWithCondition_Match_True() + { + var condition = Condition.SortedSetContainsStarting("myzset", "pre"); + var message = CreateConditionMessage(condition, RedisCommand.ZRANGEBYLEX, "myzset", "[pre", "+", "LIMIT", 0, 1); + var result = Execute("*1\r\n$6\r\nprefix\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void StartsWithCondition_NoMatch_False() + { + var condition = Condition.SortedSetContainsStarting("myzset", "pre"); + var message = CreateConditionMessage(condition, RedisCommand.ZRANGEBYLEX, "myzset", "[pre", "+", "LIMIT", 0, 1); + var result = Execute("*0\r\n", Condition.ConditionProcessor.Default, message); + Assert.False(result); + } + + [Fact] + public void StartsWithCondition_NotContainsStarting_True() + { + var condition = Condition.SortedSetNotContainsStarting("myzset", "pre"); + var message = CreateConditionMessage(condition, RedisCommand.ZRANGEBYLEX, "myzset", "[pre", "+", "LIMIT", 0, 1); + var result = Execute("*0\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void EqualsCondition_StringEqual_True() + { + var condition = Condition.StringEqual("mykey", "value1"); + var message = CreateConditionMessage(condition, RedisCommand.GET, "mykey", RedisValue.Null); + var result = Execute("$6\r\nvalue1\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void EqualsCondition_StringEqual_False() + { + var condition = Condition.StringEqual("mykey", "value1"); + var message = CreateConditionMessage(condition, RedisCommand.GET, "mykey", RedisValue.Null); + var result = Execute("$6\r\nvalue2\r\n", Condition.ConditionProcessor.Default, message); + Assert.False(result); + } + + [Fact] + public void EqualsCondition_StringNotEqual_True() + { + var condition = Condition.StringNotEqual("mykey", "value1"); + var message = CreateConditionMessage(condition, RedisCommand.GET, "mykey", RedisValue.Null); + var result = Execute("$6\r\nvalue2\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void EqualsCondition_HashEqual_True() + { + var condition = Condition.HashEqual("myhash", "field1", "value1"); + var message = CreateConditionMessage(condition, RedisCommand.HGET, "myhash", "field1"); + var result = Execute("$6\r\nvalue1\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void EqualsCondition_HashNotEqual_True() + { + var condition = Condition.HashNotEqual("myhash", "field1", "value1"); + var message = CreateConditionMessage(condition, RedisCommand.HGET, "myhash", "field1"); + var result = Execute("$6\r\nvalue2\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void EqualsCondition_SortedSetEqual_True() + { + var condition = Condition.SortedSetEqual("myzset", "member1", 5.0); + var message = CreateConditionMessage(condition, RedisCommand.ZSCORE, "myzset", "member1"); + var result = Execute("$1\r\n5\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void EqualsCondition_SortedSetEqual_False() + { + var condition = Condition.SortedSetEqual("myzset", "member1", 5.0); + var message = CreateConditionMessage(condition, RedisCommand.ZSCORE, "myzset", "member1"); + var result = Execute("$1\r\n3\r\n", Condition.ConditionProcessor.Default, message); + Assert.False(result); + } + + [Fact] + public void EqualsCondition_SortedSetNotEqual_True() + { + var condition = Condition.SortedSetNotEqual("myzset", "member1", 5.0); + var message = CreateConditionMessage(condition, RedisCommand.ZSCORE, "myzset", "member1"); + var result = Execute("$1\r\n3\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void ListCondition_IndexEqual_True() + { + var condition = Condition.ListIndexEqual("mylist", 0, "value1"); + var message = CreateConditionMessage(condition, RedisCommand.LINDEX, "mylist", 0); + var result = Execute("$6\r\nvalue1\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void ListCondition_IndexEqual_False() + { + var condition = Condition.ListIndexEqual("mylist", 0, "value1"); + var message = CreateConditionMessage(condition, RedisCommand.LINDEX, "mylist", 0); + var result = Execute("$6\r\nvalue2\r\n", Condition.ConditionProcessor.Default, message); + Assert.False(result); + } + + [Fact] + public void ListCondition_IndexNotEqual_True() + { + var condition = Condition.ListIndexNotEqual("mylist", 0, "value1"); + var message = CreateConditionMessage(condition, RedisCommand.LINDEX, "mylist", 0); + var result = Execute("$6\r\nvalue2\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void ListCondition_IndexExists_True() + { + var condition = Condition.ListIndexExists("mylist", 0); + var message = CreateConditionMessage(condition, RedisCommand.LINDEX, "mylist", 0); + var result = Execute("$6\r\nvalue1\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void ListCondition_IndexExists_Null_False() + { + var condition = Condition.ListIndexExists("mylist", 0); + var message = CreateConditionMessage(condition, RedisCommand.LINDEX, "mylist", 0); + var result = Execute("$-1\r\n", Condition.ConditionProcessor.Default, message); + Assert.False(result); + } + + [Fact] + public void ListCondition_IndexNotExists_True() + { + var condition = Condition.ListIndexNotExists("mylist", 0); + var message = CreateConditionMessage(condition, RedisCommand.LINDEX, "mylist", 0); + var result = Execute("$-1\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void LengthCondition_StringLengthEqual_True() + { + var condition = Condition.StringLengthEqual("mykey", 10); + var message = CreateConditionMessage(condition, RedisCommand.STRLEN, "mykey"); + var result = Execute(":10\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void LengthCondition_StringLengthEqual_False() + { + var condition = Condition.StringLengthEqual("mykey", 10); + var message = CreateConditionMessage(condition, RedisCommand.STRLEN, "mykey"); + var result = Execute(":5\r\n", Condition.ConditionProcessor.Default, message); + Assert.False(result); + } + + [Fact] + public void LengthCondition_StringLengthLessThan_True() + { + var condition = Condition.StringLengthLessThan("mykey", 10); + var message = CreateConditionMessage(condition, RedisCommand.STRLEN, "mykey"); + var result = Execute(":5\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void LengthCondition_StringLengthGreaterThan_True() + { + var condition = Condition.StringLengthGreaterThan("mykey", 10); + var message = CreateConditionMessage(condition, RedisCommand.STRLEN, "mykey"); + var result = Execute(":15\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void LengthCondition_HashLengthEqual_True() + { + var condition = Condition.HashLengthEqual("myhash", 5); + var message = CreateConditionMessage(condition, RedisCommand.HLEN, "myhash"); + var result = Execute(":5\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void LengthCondition_ListLengthEqual_True() + { + var condition = Condition.ListLengthEqual("mylist", 3); + var message = CreateConditionMessage(condition, RedisCommand.LLEN, "mylist"); + var result = Execute(":3\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void LengthCondition_SetLengthEqual_True() + { + var condition = Condition.SetLengthEqual("myset", 7); + var message = CreateConditionMessage(condition, RedisCommand.SCARD, "myset"); + var result = Execute(":7\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void LengthCondition_SortedSetLengthEqual_True() + { + var condition = Condition.SortedSetLengthEqual("myzset", 4); + var message = CreateConditionMessage(condition, RedisCommand.ZCARD, "myzset"); + var result = Execute(":4\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void LengthCondition_StreamLengthEqual_True() + { + var condition = Condition.StreamLengthEqual("mystream", 10); + var message = CreateConditionMessage(condition, RedisCommand.XLEN, "mystream"); + var result = Execute(":10\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void SortedSetRangeLengthCondition_Equal_True() + { + var condition = Condition.SortedSetLengthEqual("myzset", 5, 0, 10); + var message = CreateConditionMessage(condition, RedisCommand.ZCOUNT, "myzset", 0, 10); + var result = Execute(":5\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void SortedSetRangeLengthCondition_LessThan_True() + { + var condition = Condition.SortedSetLengthLessThan("myzset", 10, 0, 100); + var message = CreateConditionMessage(condition, RedisCommand.ZCOUNT, "myzset", 0, 100); + var result = Execute(":5\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void SortedSetRangeLengthCondition_GreaterThan_True() + { + var condition = Condition.SortedSetLengthGreaterThan("myzset", 3, 0, 100); + var message = CreateConditionMessage(condition, RedisCommand.ZCOUNT, "myzset", 0, 100); + var result = Execute(":10\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void SortedSetScoreCondition_ScoreExists_True() + { + var condition = Condition.SortedSetScoreExists("myzset", 5.0); + var message = CreateConditionMessage(condition, RedisCommand.ZCOUNT, "myzset", 5.0, 5.0); + var result = Execute(":3\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } + + [Fact] + public void SortedSetScoreCondition_ScoreExists_False() + { + var condition = Condition.SortedSetScoreExists("myzset", 5.0); + var message = CreateConditionMessage(condition, RedisCommand.ZCOUNT, "myzset", 5.0, 5.0); + var result = Execute(":0\r\n", Condition.ConditionProcessor.Default, message); + Assert.False(result); + } + + [Fact] + public void SortedSetScoreCondition_ScoreNotExists_True() + { + var condition = Condition.SortedSetScoreNotExists("myzset", 5.0); + var message = CreateConditionMessage(condition, RedisCommand.ZCOUNT, "myzset", 5.0, 5.0); + var result = Execute(":0\r\n", Condition.ConditionProcessor.Default, message); + Assert.True(result); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/DateTime.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/DateTime.cs new file mode 100644 index 000000000..9dc4bc954 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/DateTime.cs @@ -0,0 +1,79 @@ +using System; +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +/// +/// Tests for DateTime result processors +/// +public class DateTimeTests(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Theory] + [InlineData(":1609459200\r\n")] // scalar integer (Jan 1, 2021 00:00:00 UTC) + [InlineData("*1\r\n:1609459200\r\n")] // array of 1 (seconds only) + [InlineData("*?\r\n:1609459200\r\n.\r\n")] // streaming aggregate of 1 + [InlineData(ATTRIB_FOO_BAR + ":1609459200\r\n")] + public void DateTime(string resp) + { + var expected = new DateTime(2021, 1, 1, 0, 0, 0, DateTimeKind.Utc); + Assert.Equal(expected, Execute(resp, ResultProcessor.DateTime)); + } + + [Theory] + [InlineData("*2\r\n:1609459200\r\n:500000\r\n")] // array of 2 (seconds + microseconds) + [InlineData("*?\r\n:1609459200\r\n:500000\r\n.\r\n")] // streaming aggregate of 2 + [InlineData(ATTRIB_FOO_BAR + "*2\r\n:1609459200\r\n:500000\r\n")] + public void DateTimeWithMicroseconds(string resp) + { + // 500000 microseconds = 0.5 seconds = 5000000 ticks (100ns each) + var expected = new DateTime(2021, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddTicks(5000000); + Assert.Equal(expected, Execute(resp, ResultProcessor.DateTime)); + } + + [Theory] + [InlineData("*0\r\n")] // empty array + [InlineData("*?\r\n.\r\n")] // streaming empty aggregate + [InlineData("*3\r\n:1\r\n:2\r\n:3\r\n")] // array with 3 elements + [InlineData("$5\r\nhello\r\n")] // bulk string + public void FailingDateTime(string resp) => ExecuteUnexpected(resp, ResultProcessor.DateTime); + + [Theory] + [InlineData(":1609459200\r\n")] // positive value (Jan 1, 2021 00:00:00 UTC) - seconds + [InlineData(",1609459200\r\n")] // RESP3 number + [InlineData(ATTRIB_FOO_BAR + ":1609459200\r\n")] + public void NullableDateTimeFromSeconds(string resp) + { + var expected = new DateTime(2021, 1, 1, 0, 0, 0, DateTimeKind.Utc); + Assert.Equal(expected, Execute(resp, ResultProcessor.NullableDateTimeFromSeconds)); + } + + [Theory] + [InlineData(":1609459200000\r\n")] // positive value (Jan 1, 2021 00:00:00 UTC) - milliseconds + [InlineData(",1609459200000\r\n")] // RESP3 number + [InlineData(ATTRIB_FOO_BAR + ":1609459200000\r\n")] + public void NullableDateTimeFromMilliseconds(string resp) + { + var expected = new DateTime(2021, 1, 1, 0, 0, 0, DateTimeKind.Utc); + Assert.Equal(expected, Execute(resp, ResultProcessor.NullableDateTimeFromMilliseconds)); + } + + [Theory] + [InlineData(":-1\r\n", null)] // -1 means no expiry + [InlineData(":-2\r\n", null)] // -2 means key does not exist + [InlineData("_\r\n", null)] // RESP3 null + [InlineData("$-1\r\n", null)] // RESP2 null bulk string + public void NullableDateTimeNull(string resp, DateTime? expected) + { + Assert.Equal(expected, Execute(resp, ResultProcessor.NullableDateTimeFromSeconds)); + Assert.Equal(expected, Execute(resp, ResultProcessor.NullableDateTimeFromMilliseconds)); + } + + [Theory] + [InlineData("*0\r\n")] // empty array + [InlineData("*2\r\n:1\r\n:2\r\n")] // array + public void FailingNullableDateTime(string resp) + { + ExecuteUnexpected(resp, ResultProcessor.NullableDateTimeFromSeconds); + ExecuteUnexpected(resp, ResultProcessor.NullableDateTimeFromMilliseconds); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/DemandZeroOrOne.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/DemandZeroOrOne.cs new file mode 100644 index 000000000..e5ce63ce6 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/DemandZeroOrOne.cs @@ -0,0 +1,29 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class DemandZeroOrOne(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Theory] + [InlineData(":0\r\n", false)] + [InlineData(":1\r\n", true)] + [InlineData("+0\r\n", false)] + [InlineData("+1\r\n", true)] + [InlineData("$1\r\n0\r\n", false)] + [InlineData("$1\r\n1\r\n", true)] + public void ValidZeroOrOne_Success(string resp, bool expected) + { + var result = Execute(resp, ResultProcessor.DemandZeroOrOne); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(":2\r\n")] + [InlineData("+OK\r\n")] + [InlineData("*1\r\n:1\r\n")] + [InlineData("$-1\r\n")] + public void InvalidResponse_Failure(string resp) + { + ExecuteUnexpected(resp, ResultProcessor.DemandZeroOrOne); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/ExpectBasicString.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/ExpectBasicString.cs new file mode 100644 index 000000000..a6fc80a42 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/ExpectBasicString.cs @@ -0,0 +1,58 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class ExpectBasicString(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Theory] + [InlineData("+OK\r\n", true)] + [InlineData("$2\r\nOK\r\n", true)] + public void DemandOK_Success(string resp, bool expected) => Assert.Equal(expected, Execute(resp, ResultProcessor.DemandOK)); + + [Theory] + [InlineData("+PONG\r\n", true)] + [InlineData("$4\r\nPONG\r\n", true)] + public void DemandPONG_Success(string resp, bool expected) => Assert.Equal(expected, Execute(resp, ResultProcessor.DemandPONG)); + + [Theory] + [InlineData("+FAIL\r\n")] + [InlineData("$4\r\nFAIL\r\n")] + [InlineData(":1\r\n")] + public void DemandOK_Failure(string resp) => Assert.False(TryExecute(resp, ResultProcessor.DemandOK, out _, out _)); + + [Theory] + [InlineData("+FAIL\r\n")] + [InlineData("$4\r\nFAIL\r\n")] + [InlineData(":1\r\n")] + public void DemandPONG_Failure(string resp) => Assert.False(TryExecute(resp, ResultProcessor.DemandPONG, out _, out _)); + + [Theory] + [InlineData("+Background saving started\r\n", true)] + [InlineData("$25\r\nBackground saving started\r\n", true)] + [InlineData("+Background saving started by parent\r\n", true)] + public void BackgroundSaveStarted_Success(string resp, bool expected) => Assert.Equal(expected, Execute(resp, ResultProcessor.BackgroundSaveStarted)); + + [Theory] + [InlineData("+Background append only file rewriting started\r\n", true)] + [InlineData("$45\r\nBackground append only file rewriting started\r\n", true)] + public void BackgroundSaveAOFStarted_Success(string resp, bool expected) => Assert.Equal(expected, Execute(resp, ResultProcessor.BackgroundSaveAOFStarted)); + + // Case sensitivity tests - these demonstrate that the new implementation is case-sensitive + // The old CommandBytes implementation was case-insensitive (stored uppercase) + [Theory] + [InlineData("+ok\r\n")] // lowercase + [InlineData("+Ok\r\n")] // mixed case + [InlineData("$2\r\nok\r\n")] // lowercase bulk string + public void DemandOK_CaseSensitive_Failure(string resp) => Assert.False(TryExecute(resp, ResultProcessor.DemandOK, out _, out _)); + + [Theory] + [InlineData("+pong\r\n")] // lowercase + [InlineData("+Pong\r\n")] // mixed case + [InlineData("$4\r\npong\r\n")] // lowercase bulk string + public void DemandPONG_CaseSensitive_Failure(string resp) => Assert.False(TryExecute(resp, ResultProcessor.DemandPONG, out _, out _)); + + [Theory] + [InlineData("+background saving started\r\n")] // lowercase + [InlineData("+BACKGROUND SAVING STARTED\r\n")] // uppercase + public void BackgroundSaveStarted_CaseSensitive_Failure(string resp) => Assert.False(TryExecute(resp, ResultProcessor.BackgroundSaveStarted, out _, out _)); +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/GeoPosition.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/GeoPosition.cs new file mode 100644 index 000000000..23436aff8 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/GeoPosition.cs @@ -0,0 +1,118 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class GeoPosition(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Fact] + public void GeoPosition_ValidPosition_ReturnsGeoPosition() + { + var resp = "*1\r\n*2\r\n$18\r\n13.361389338970184\r\n$16\r\n38.1155563954963\r\n"; + var processor = ResultProcessor.RedisGeoPosition; + var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(13.361389338970184, result.Value.Longitude, 10); + Assert.Equal(38.1155563954963, result.Value.Latitude, 10); + } + + [Fact] + public void GeoPosition_NullElement_ReturnsNull() + { + var resp = "*1\r\n$-1\r\n"; + var processor = ResultProcessor.RedisGeoPosition; + var result = Execute(resp, processor); + + Assert.Null(result); + } + + [Fact] + public void GeoPosition_EmptyArray_ReturnsNull() + { + var resp = "*0\r\n"; + var processor = ResultProcessor.RedisGeoPosition; + var result = Execute(resp, processor); + + Assert.Null(result); + } + + [Fact] + public void GeoPosition_NullArray_ReturnsNull() + { + var resp = "*-1\r\n"; + var processor = ResultProcessor.RedisGeoPosition; + var result = Execute(resp, processor); + + Assert.Null(result); + } + + [Fact] + public void GeoPosition_IntegerCoordinates_ReturnsGeoPosition() + { + var resp = "*1\r\n*2\r\n:13\r\n:38\r\n"; + var processor = ResultProcessor.RedisGeoPosition; + var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(13.0, result.Value.Longitude); + Assert.Equal(38.0, result.Value.Latitude); + } + + [Fact] + public void GeoPositionArray_MultiplePositions_ReturnsArray() + { + var resp = "*3\r\n" + + "*2\r\n$18\r\n13.361389338970184\r\n$16\r\n38.1155563954963\r\n" + + "*2\r\n$18\r\n15.087267458438873\r\n$17\r\n37.50266842333162\r\n" + + "$-1\r\n"; + var processor = ResultProcessor.RedisGeoPositionArray; + var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(3, result.Length); + + Assert.NotNull(result[0]); + Assert.Equal(13.361389338970184, result[0]!.Value.Longitude, 10); + Assert.Equal(38.1155563954963, result[0]!.Value.Latitude, 10); + + Assert.NotNull(result[1]); + Assert.Equal(15.087267458438873, result[1]!.Value.Longitude, 10); + Assert.Equal(37.50266842333162, result[1]!.Value.Latitude, 10); + + Assert.Null(result[2]); + } + + [Fact] + public void GeoPositionArray_EmptyArray_ReturnsEmptyArray() + { + var resp = "*0\r\n"; + var processor = ResultProcessor.RedisGeoPositionArray; + var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void GeoPositionArray_NullArray_ReturnsNull() + { + var resp = "*-1\r\n"; + var processor = ResultProcessor.RedisGeoPositionArray; + var result = Execute(resp, processor); + + Assert.Null(result); + } + + [Fact] + public void GeoPositionArray_AllNulls_ReturnsArrayOfNulls() + { + var resp = "*2\r\n$-1\r\n$-1\r\n"; + var processor = ResultProcessor.RedisGeoPositionArray; + var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(2, result.Length); + Assert.Null(result[0]); + Assert.Null(result[1]); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/GeoRadius.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/GeoRadius.cs new file mode 100644 index 000000000..049f8013e --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/GeoRadius.cs @@ -0,0 +1,141 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class GeoRadius(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Fact] + public void GeoRadius_None_ReturnsJustMembers() + { + // Without any WITH option: just member names as scalars in array + var resp = "*2\r\n$7\r\nPalermo\r\n$7\r\nCatania\r\n"; + var result = Execute(resp, ResultProcessor.GeoRadiusArray(GeoRadiusOptions.None)); + + Assert.NotNull(result); + Assert.Equal(2, result.Length); + Assert.Equal("Palermo", result[0].Member); + Assert.Null(result[0].Distance); + Assert.Null(result[0].Hash); + Assert.Null(result[0].Position); + Assert.Equal("Catania", result[1].Member); + Assert.Null(result[1].Distance); + Assert.Null(result[1].Hash); + Assert.Null(result[1].Position); + } + + [Fact] + public void GeoRadius_WithDistance_ReturnsDistances() + { + // With WITHDIST: each element is [member, distance] + var resp = "*2\r\n" + + "*2\r\n$7\r\nPalermo\r\n$8\r\n190.4424\r\n" + + "*2\r\n$7\r\nCatania\r\n$7\r\n56.4413\r\n"; + var result = Execute(resp, ResultProcessor.GeoRadiusArray(GeoRadiusOptions.WithDistance)); + + Assert.NotNull(result); + Assert.Equal(2, result.Length); + Assert.Equal("Palermo", result[0].Member); + Assert.Equal(190.4424, result[0].Distance); + Assert.Null(result[0].Hash); + Assert.Null(result[0].Position); + Assert.Equal("Catania", result[1].Member); + Assert.Equal(56.4413, result[1].Distance); + Assert.Null(result[1].Hash); + Assert.Null(result[1].Position); + } + + [Fact] + public void GeoRadius_WithCoordinates_ReturnsPositions() + { + // With WITHCOORD: each element is [member, [longitude, latitude]] + var resp = "*2\r\n" + + "*2\r\n$7\r\nPalermo\r\n*2\r\n$18\r\n13.361389338970184\r\n$16\r\n38.1155563954963\r\n" + + "*2\r\n$7\r\nCatania\r\n*2\r\n$18\r\n15.087267458438873\r\n$17\r\n37.50266842333162\r\n"; + var result = Execute(resp, ResultProcessor.GeoRadiusArray(GeoRadiusOptions.WithCoordinates)); + + Assert.NotNull(result); + Assert.Equal(2, result.Length); + Assert.Equal("Palermo", result[0].Member); + Assert.Null(result[0].Distance); + Assert.Null(result[0].Hash); + Assert.NotNull(result[0].Position); + Assert.Equal(13.361389338970184, result[0].Position!.Value.Longitude); + Assert.Equal(38.1155563954963, result[0].Position!.Value.Latitude); + Assert.Equal("Catania", result[1].Member); + Assert.Null(result[1].Distance); + Assert.Null(result[1].Hash); + Assert.NotNull(result[1].Position); + Assert.Equal(15.087267458438873, result[1].Position!.Value.Longitude); + Assert.Equal(37.50266842333162, result[1].Position!.Value.Latitude); + } + + [Fact] + public void GeoRadius_WithDistanceAndCoordinates_ReturnsBoth() + { + // With WITHDIST WITHCOORD: each element is [member, distance, [longitude, latitude]] + var resp = "*2\r\n" + + "*3\r\n$7\r\nPalermo\r\n$8\r\n190.4424\r\n*2\r\n$18\r\n13.361389338970184\r\n$16\r\n38.1155563954963\r\n" + + "*3\r\n$7\r\nCatania\r\n$7\r\n56.4413\r\n*2\r\n$18\r\n15.087267458438873\r\n$17\r\n37.50266842333162\r\n"; + var result = Execute(resp, ResultProcessor.GeoRadiusArray(GeoRadiusOptions.WithDistance | GeoRadiusOptions.WithCoordinates)); + + Assert.NotNull(result); + Assert.Equal(2, result.Length); + Assert.Equal("Palermo", result[0].Member); + Assert.Equal(190.4424, result[0].Distance); + Assert.Null(result[0].Hash); + Assert.NotNull(result[0].Position); + Assert.Equal(13.361389338970184, result[0].Position!.Value.Longitude); + Assert.Equal(38.1155563954963, result[0].Position!.Value.Latitude); + } + + [Fact] + public void GeoRadius_WithHash_ReturnsHash() + { + // With WITHHASH: each element is [member, hash] + var resp = "*2\r\n" + + "*2\r\n$7\r\nPalermo\r\n:3479099956230698\r\n" + + "*2\r\n$7\r\nCatania\r\n:3479447370796909\r\n"; + var result = Execute(resp, ResultProcessor.GeoRadiusArray(GeoRadiusOptions.WithGeoHash)); + + Assert.NotNull(result); + Assert.Equal(2, result.Length); + Assert.Equal("Palermo", result[0].Member); + Assert.Null(result[0].Distance); + Assert.Equal(3479099956230698, result[0].Hash); + Assert.Null(result[0].Position); + Assert.Equal("Catania", result[1].Member); + Assert.Null(result[1].Distance); + Assert.Equal(3479447370796909, result[1].Hash); + Assert.Null(result[1].Position); + } + + [Fact] + public void GeoRadius_AllOptions_ReturnsEverything() + { + // With all options: [member, distance, hash, [longitude, latitude]] + var resp = "*1\r\n" + + "*4\r\n$7\r\nPalermo\r\n$8\r\n190.4424\r\n:3479099956230698\r\n*2\r\n$18\r\n13.361389338970184\r\n$16\r\n38.1155563954963\r\n"; + var result = Execute( + resp, + ResultProcessor.GeoRadiusArray(GeoRadiusOptions.WithDistance | GeoRadiusOptions.WithGeoHash | GeoRadiusOptions.WithCoordinates)); + + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("Palermo", result[0].Member); + Assert.Equal(190.4424, result[0].Distance); + Assert.Equal(3479099956230698, result[0].Hash); + Assert.NotNull(result[0].Position); + Assert.Equal(13.361389338970184, result[0].Position!.Value.Longitude); + Assert.Equal(38.1155563954963, result[0].Position!.Value.Latitude); + } + + [Fact] + public void GeoRadius_EmptyArray_ReturnsEmptyArray() + { + var resp = "*0\r\n"; + var result = Execute(resp, ResultProcessor.GeoRadiusArray(GeoRadiusOptions.None)); + + Assert.NotNull(result); + Assert.Empty(result); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/HotKeys.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/HotKeys.cs new file mode 100644 index 000000000..dc780ae1d --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/HotKeys.cs @@ -0,0 +1,145 @@ +using System; +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class HotKeys(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Fact] + public void FullFormat_Success() + { + // HOTKEYS GET - full response with all fields + // Carefully counted byte lengths for each string + var resp = "*1\r\n" + + "*24\r\n" + + "$15\r\ntracking-active\r\n" + + ":0\r\n" + + "$12\r\nsample-ratio\r\n" + + ":1\r\n" + + "$14\r\nselected-slots\r\n" + + "*1\r\n" + + "*2\r\n" + + ":0\r\n" + + ":16383\r\n" + + "$25\r\nall-commands-all-slots-us\r\n" + + ":103\r\n" + + "$32\r\nnet-bytes-all-commands-all-slots\r\n" + + ":2042\r\n" + + "$29\r\ncollection-start-time-unix-ms\r\n" + + ":1770824933147\r\n" + + "$22\r\ncollection-duration-ms\r\n" + + ":0\r\n" + + "$22\r\ntotal-cpu-time-user-ms\r\n" + + ":23\r\n" + + "$21\r\ntotal-cpu-time-sys-ms\r\n" + + ":7\r\n" + + "$15\r\ntotal-net-bytes\r\n" + + ":2038\r\n" + + "$14\r\nby-cpu-time-us\r\n" + + "*10\r\n" + + "$18\r\nhotkey_001_counter\r\n" + + ":29\r\n" + + "$10\r\nhotkey_001\r\n" + + ":25\r\n" + + "$15\r\nhotkey_001_hash\r\n" + + ":11\r\n" + + "$15\r\nhotkey_001_list\r\n" + + ":9\r\n" + + "$14\r\nhotkey_001_set\r\n" + + ":9\r\n" + + "$12\r\nby-net-bytes\r\n" + + "*10\r\n" + + "$10\r\nhotkey_001\r\n" + + ":446\r\n" + + "$10\r\nhotkey_002\r\n" + + ":328\r\n" + + "$15\r\nhotkey_001_hash\r\n" + + ":198\r\n" + + "$14\r\nhotkey_001_set\r\n" + + ":167\r\n" + + "$18\r\nhotkey_001_counter\r\n" + + ":116\r\n"; + + var result = Execute(resp, HotKeysResult.Processor); + + Assert.NotNull(result); + Assert.False(result.TrackingActive); + Assert.Equal(1, result.SampleRatio); + Assert.Equal(103, result.AllCommandsAllSlotsMicroseconds); + Assert.Equal(2042, result.AllCommandsAllSlotsNetworkBytes); + Assert.Equal(1770824933147, result.CollectionStartTimeUnixMilliseconds); + Assert.Equal(0, result.CollectionDurationMicroseconds); + Assert.Equal(23000, result.TotalCpuTimeUserMicroseconds); + Assert.Equal(7000, result.TotalCpuTimeSystemMicroseconds); + Assert.Equal(2038, result.TotalNetworkBytes); + + // Validate TimeSpan properties + // 103 microseconds = 0.103 milliseconds + Assert.Equal(0.103, result.AllCommandsAllSlotsTime.TotalMilliseconds, precision: 10); + Assert.Equal(TimeSpan.Zero, result.CollectionDuration); + // 23000 microseconds = 23 milliseconds + Assert.Equal(23.0, result.TotalCpuTimeUser!.Value.TotalMilliseconds, precision: 10); + // 7000 microseconds = 7 milliseconds + Assert.Equal(7.0, result.TotalCpuTimeSystem!.Value.TotalMilliseconds, precision: 10); + // 30000 microseconds = 30 milliseconds + Assert.Equal(30.0, result.TotalCpuTime!.Value.TotalMilliseconds, precision: 10); + + // Validate by-cpu-time-us array + Assert.Equal(5, result.CpuByKey.Length); + Assert.Equal("hotkey_001_counter", (string?)result.CpuByKey[0].Key); + Assert.Equal(29, result.CpuByKey[0].DurationMicroseconds); + Assert.Equal("hotkey_001", (string?)result.CpuByKey[1].Key); + Assert.Equal(25, result.CpuByKey[1].DurationMicroseconds); + Assert.Equal("hotkey_001_hash", (string?)result.CpuByKey[2].Key); + Assert.Equal(11, result.CpuByKey[2].DurationMicroseconds); + Assert.Equal("hotkey_001_list", (string?)result.CpuByKey[3].Key); + Assert.Equal(9, result.CpuByKey[3].DurationMicroseconds); + Assert.Equal("hotkey_001_set", (string?)result.CpuByKey[4].Key); + Assert.Equal(9, result.CpuByKey[4].DurationMicroseconds); + + // Validate by-net-bytes array + Assert.Equal(5, result.NetworkBytesByKey.Length); + Assert.Equal("hotkey_001", (string?)result.NetworkBytesByKey[0].Key); + Assert.Equal(446, result.NetworkBytesByKey[0].Bytes); + Assert.Equal("hotkey_002", (string?)result.NetworkBytesByKey[1].Key); + Assert.Equal(328, result.NetworkBytesByKey[1].Bytes); + Assert.Equal("hotkey_001_hash", (string?)result.NetworkBytesByKey[2].Key); + Assert.Equal(198, result.NetworkBytesByKey[2].Bytes); + Assert.Equal("hotkey_001_set", (string?)result.NetworkBytesByKey[3].Key); + Assert.Equal(167, result.NetworkBytesByKey[3].Bytes); + Assert.Equal("hotkey_001_counter", (string?)result.NetworkBytesByKey[4].Key); + Assert.Equal(116, result.NetworkBytesByKey[4].Bytes); + } + + [Fact] + public void MinimalFormat_Success() + { + // Minimal HOTKEYS response with just tracking-active + var resp = "*1\r\n" + + "*2\r\n" + + "$15\r\ntracking-active\r\n" + + ":1\r\n"; + + var result = Execute(resp, HotKeysResult.Processor); + + Assert.NotNull(result); + Assert.True(result.TrackingActive); + } + + [Fact] + public void NotArray_Failure() + { + var resp = "$5\r\nhello\r\n"; + + ExecuteUnexpected(resp, HotKeysResult.Processor); + } + + [Fact] + public void Null_Success() + { + var resp = "$-1\r\n"; + + var result = Execute(resp, HotKeysResult.Processor); + Assert.Null(result); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Info.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Info.cs new file mode 100644 index 000000000..82c01c458 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Info.cs @@ -0,0 +1,80 @@ +using System.Linq; +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class Info(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Fact] + public void SingleSection_Success() + { + var resp = "$651\r\n# Server\r\nredis_version:8.6.0\r\nredis_git_sha1:00000000\r\nredis_git_dirty:1\r\nredis_build_id:a7d515010e105f80\r\nredis_mode:standalone\r\nos:Linux 6.17.0-14-generic x86_64\r\narch_bits:64\r\nmonotonic_clock:POSIX clock_gettime\r\nmultiplexing_api:epoll\r\natomicvar_api:c11-builtin\r\ngcc_version:14.2.0\r\nprocess_id:16\r\nprocess_supervised:no\r\nrun_id:b5ab1b382ec845e0a6989e550f36c187fdef3bc0\r\ntcp_port:3000\r\nserver_time_usec:1771514460547930\r\nuptime_in_seconds:13\r\nuptime_in_days:0\r\nhz:10\r\nconfigured_hz:10\r\nlru_clock:9906780\r\nexecutable:/data/redis-server\r\nconfig_file:/redis/work/node-0/redis.conf\r\nio_threads_active:0\r\nlistener0:name=tcp,bind=*,bind=-::*,port=3000\r\n\r\n"; + + var result = Execute(resp, ResultProcessor.Info); + + Assert.NotNull(result); + Assert.Single(result); + + var serverSection = result.Single(g => g.Key == "Server"); + Assert.Equal(25, serverSection.Count()); + + var versionPair = serverSection.First(kv => kv.Key == "redis_version"); + Assert.Equal("8.6.0", versionPair.Value); + + var portPair = serverSection.First(kv => kv.Key == "tcp_port"); + Assert.Equal("3000", portPair.Value); + } + + [Fact] + public void MultipleSection_Success() + { + var resp = "$978\r\n# Server\r\nredis_version:8.6.0\r\nredis_git_sha1:00000000\r\nredis_git_dirty:1\r\nredis_build_id:a7d515010e105f80\r\nredis_mode:standalone\r\nos:Linux 6.17.0-14-generic x86_64\r\narch_bits:64\r\nmonotonic_clock:POSIX clock_gettime\r\nmultiplexing_api:epoll\r\natomicvar_api:c11-builtin\r\ngcc_version:14.2.0\r\nprocess_id:16\r\nprocess_supervised:no\r\nrun_id:b5ab1b382ec845e0a6989e550f36c187fdef3bc0\r\ntcp_port:3000\r\nserver_time_usec:1771514577242937\r\nuptime_in_seconds:130\r\nuptime_in_days:0\r\nhz:10\r\nconfigured_hz:10\r\nlru_clock:9906897\r\nexecutable:/data/redis-server\r\nconfig_file:/redis/work/node-0/redis.conf\r\nio_threads_active:0\r\nlistener0:name=tcp,bind=*,bind=-::*,port=3000\r\n\r\n# Clients\r\nconnected_clients:1\r\ncluster_connections:0\r\nmaxclients:10000\r\nclient_recent_max_input_buffer:0\r\nclient_recent_max_output_buffer:0\r\nblocked_clients:0\r\ntracking_clients:0\r\npubsub_clients:0\r\nwatching_clients:0\r\nclients_in_timeout_table:0\r\ntotal_watched_keys:0\r\ntotal_blocking_keys:0\r\ntotal_blocking_keys_on_nokey:0\r\n\r\n"; + + var result = Execute(resp, ResultProcessor.Info); + + Assert.NotNull(result); + Assert.Equal(2, result.Length); + + var serverSection = result.Single(g => g.Key == "Server"); + Assert.Equal(25, serverSection.Count()); + + var clientsSection = result.Single(g => g.Key == "Clients"); + Assert.Equal(13, clientsSection.Count()); + + var connectedClients = clientsSection.First(kv => kv.Key == "connected_clients"); + Assert.Equal("1", connectedClients.Value); + } + + [Fact] + public void EmptyString_Success() + { + var resp = "$0\r\n\r\n"; + var result = Execute(resp, ResultProcessor.Info); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void NullBulkString_Success() + { + var resp = "$-1\r\n"; + var result = Execute(resp, ResultProcessor.Info); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void NoSectionHeader_UsesDefaultCategory() + { + var resp = "$26\r\nkey1:value1\r\nkey2:value2\r\n\r\n"; + var result = Execute(resp, ResultProcessor.Info); + + Assert.NotNull(result); + Assert.Single(result); + + var miscSection = result.Single(g => g.Key == "miscellaneous"); + Assert.Equal(2, miscSection.Count()); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/InterleavedPairs.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/InterleavedPairs.cs new file mode 100644 index 000000000..5053f5489 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/InterleavedPairs.cs @@ -0,0 +1,200 @@ +using System.Linq; +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +/// +/// Tests for ValuePairInterleavedProcessorBase and its derived processors. +/// These processors handle both interleaved (RESP2) and jagged (RESP3) formats. +/// +public class InterleavedPairs(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + // HashEntryArrayProcessor tests + [Theory] + // RESP2 interleaved format: [key, value, key, value, ...] + [InlineData("*4\r\n$3\r\nkey\r\n$5\r\nvalue\r\n$4\r\nkey2\r\n$6\r\nvalue2\r\n", "key=value,key2=value2")] + [InlineData("*2\r\n$1\r\na\r\n$1\r\nb\r\n", "a=b")] + [InlineData("*0\r\n", "")] + [InlineData("*-1\r\n", null)] + // RESP3 map format (alternative aggregate type, still linear): %{key: value, key2: value2} + [InlineData("%2\r\n$3\r\nkey\r\n$5\r\nvalue\r\n$4\r\nkey2\r\n$6\r\nvalue2\r\n", "key=value,key2=value2", RedisProtocol.Resp3)] + [InlineData("%1\r\n$1\r\na\r\n$1\r\nb\r\n", "a=b", RedisProtocol.Resp3)] + [InlineData("%0\r\n", "", RedisProtocol.Resp3)] + [InlineData("_\r\n", null, RedisProtocol.Resp3)] + // Jagged format (RESP3): [[key, value], [key, value], ...] - array of 2-element arrays + [InlineData("*2\r\n*2\r\n$3\r\nkey\r\n$5\r\nvalue\r\n*2\r\n$4\r\nkey2\r\n$6\r\nvalue2\r\n", "key=value,key2=value2", RedisProtocol.Resp3)] + [InlineData("*1\r\n*2\r\n$1\r\na\r\n$1\r\nb\r\n", "a=b", RedisProtocol.Resp3)] + [InlineData("*0\r\n", "", RedisProtocol.Resp3)] + // RESP3 with attributes + [InlineData(ATTRIB_FOO_BAR + "*4\r\n$3\r\nkey\r\n$5\r\nvalue\r\n$4\r\nkey2\r\n$6\r\nvalue2\r\n", "key=value,key2=value2")] + public void HashEntryArray(string resp, string? expected, RedisProtocol protocol = RedisProtocol.Resp2) + { + var result = Execute(resp, ResultProcessor.HashEntryArray, protocol: protocol); + if (expected == null) + { + Assert.Null(result); + } + else + { + Assert.NotNull(result); + var formatted = string.Join(",", System.Linq.Enumerable.Select(result, e => $"{e.Name}={e.Value}")); + Assert.Equal(expected, formatted); + } + } + + // SortedSetEntryArrayProcessor tests + [Theory] + // RESP2 interleaved format: [element, score, element, score, ...] + [InlineData("*4\r\n$3\r\nfoo\r\n,1.5\r\n$3\r\nbar\r\n,2.5\r\n", "foo:1.5,bar:2.5")] + [InlineData("*2\r\n$1\r\na\r\n,1.0\r\n", "a:1")] + [InlineData("*0\r\n", "")] + [InlineData("*-1\r\n", null)] + // RESP3 map format (alternative aggregate type, still linear): %{element: score, element2: score2} + [InlineData("%2\r\n$3\r\nfoo\r\n,1.5\r\n$3\r\nbar\r\n,2.5\r\n", "foo:1.5,bar:2.5", RedisProtocol.Resp3)] + [InlineData("%1\r\n$1\r\na\r\n,1.0\r\n", "a:1", RedisProtocol.Resp3)] + [InlineData("%0\r\n", "", RedisProtocol.Resp3)] + [InlineData("_\r\n", null, RedisProtocol.Resp3)] + // Jagged format (RESP3): [[element, score], [element, score], ...] - array of 2-element arrays + [InlineData("*2\r\n*2\r\n$3\r\nfoo\r\n,1.5\r\n*2\r\n$3\r\nbar\r\n,2.5\r\n", "foo:1.5,bar:2.5", RedisProtocol.Resp3)] + [InlineData("*1\r\n*2\r\n$1\r\na\r\n,1.0\r\n", "a:1", RedisProtocol.Resp3)] + [InlineData("*0\r\n", "", RedisProtocol.Resp3)] + // RESP3 with attributes + [InlineData(ATTRIB_FOO_BAR + "*4\r\n$3\r\nfoo\r\n,1.5\r\n$3\r\nbar\r\n,2.5\r\n", "foo:1.5,bar:2.5")] + public void SortedSetEntryArray(string resp, string? expected, RedisProtocol protocol = RedisProtocol.Resp2) + { + var result = Execute(resp, ResultProcessor.SortedSetWithScores, protocol: protocol); + if (expected == null) + { + Assert.Null(result); + } + else + { + Assert.NotNull(result); + var formatted = string.Join(",", System.Linq.Enumerable.Select(result, e => $"{e.Element}:{e.Score}")); + Assert.Equal(expected, formatted); + } + } + + // StreamNameValueEntryProcessor tests + [Theory] + // RESP2 interleaved format: [name, value, name, value, ...] + [InlineData("*4\r\n$4\r\nname\r\n$5\r\nvalue\r\n$5\r\nname2\r\n$6\r\nvalue2\r\n", "name=value,name2=value2")] + [InlineData("*2\r\n$1\r\na\r\n$1\r\nb\r\n", "a=b")] + [InlineData("*0\r\n", "")] + [InlineData("*-1\r\n", null)] + // RESP3 map format (alternative aggregate type, still linear): %{name: value, name2: value2} + [InlineData("%2\r\n$4\r\nname\r\n$5\r\nvalue\r\n$5\r\nname2\r\n$6\r\nvalue2\r\n", "name=value,name2=value2", RedisProtocol.Resp3)] + [InlineData("%1\r\n$1\r\na\r\n$1\r\nb\r\n", "a=b", RedisProtocol.Resp3)] + [InlineData("%0\r\n", "", RedisProtocol.Resp3)] + [InlineData("_\r\n", null, RedisProtocol.Resp3)] + // Jagged format (RESP3): [[name, value], [name, value], ...] - array of 2-element arrays + [InlineData("*2\r\n*2\r\n$4\r\nname\r\n$5\r\nvalue\r\n*2\r\n$5\r\nname2\r\n$6\r\nvalue2\r\n", "name=value,name2=value2", RedisProtocol.Resp3)] + [InlineData("*1\r\n*2\r\n$1\r\na\r\n$1\r\nb\r\n", "a=b", RedisProtocol.Resp3)] + [InlineData("*0\r\n", "", RedisProtocol.Resp3)] + // RESP3 with attributes + [InlineData(ATTRIB_FOO_BAR + "*4\r\n$4\r\nname\r\n$5\r\nvalue\r\n$5\r\nname2\r\n$6\r\nvalue2\r\n", "name=value,name2=value2")] + public void StreamNameValueEntry(string resp, string? expected, RedisProtocol protocol = RedisProtocol.Resp2) + { + var result = Execute(resp, ResultProcessor.StreamNameValueEntryProcessor.Instance, protocol: protocol); + if (expected == null) + { + Assert.Null(result); + } + else + { + Assert.NotNull(result); + var formatted = string.Join(",", System.Linq.Enumerable.Select(result, e => $"{e.Name}={e.Value}")); + Assert.Equal(expected, formatted); + } + } + + // StringPairInterleavedProcessor tests + [Theory] + // RESP2 interleaved format: [key, value, key, value, ...] + [InlineData("*4\r\n$3\r\nkey\r\n$5\r\nvalue\r\n$4\r\nkey2\r\n$6\r\nvalue2\r\n", "key=value,key2=value2")] + [InlineData("*2\r\n$1\r\na\r\n$1\r\nb\r\n", "a=b")] + [InlineData("*0\r\n", "")] + [InlineData("*-1\r\n", null)] + // RESP3 map format (alternative aggregate type, still linear): %{key: value, key2: value2} + [InlineData("%2\r\n$3\r\nkey\r\n$5\r\nvalue\r\n$4\r\nkey2\r\n$6\r\nvalue2\r\n", "key=value,key2=value2", RedisProtocol.Resp3)] + [InlineData("%1\r\n$1\r\na\r\n$1\r\nb\r\n", "a=b", RedisProtocol.Resp3)] + [InlineData("%0\r\n", "", RedisProtocol.Resp3)] + [InlineData("_\r\n", null, RedisProtocol.Resp3)] + // Jagged format (RESP3): [[key, value], [key, value], ...] - array of 2-element arrays + [InlineData("*2\r\n*2\r\n$3\r\nkey\r\n$5\r\nvalue\r\n*2\r\n$4\r\nkey2\r\n$6\r\nvalue2\r\n", "key=value,key2=value2", RedisProtocol.Resp3)] + [InlineData("*1\r\n*2\r\n$1\r\na\r\n$1\r\nb\r\n", "a=b", RedisProtocol.Resp3)] + [InlineData("*0\r\n", "", RedisProtocol.Resp3)] + // RESP3 with attributes + [InlineData(ATTRIB_FOO_BAR + "*4\r\n$3\r\nkey\r\n$5\r\nvalue\r\n$4\r\nkey2\r\n$6\r\nvalue2\r\n", "key=value,key2=value2")] + public void StringPairInterleaved(string resp, string? expected, RedisProtocol protocol = RedisProtocol.Resp2) + { + var result = Execute(resp, ResultProcessor.StringPairInterleaved, protocol: protocol); + if (expected == null) + { + Assert.Null(result); + } + else + { + Assert.NotNull(result); + var formatted = string.Join(",", System.Linq.Enumerable.Select(result, kvp => $"{kvp.Key}={kvp.Value}")); + Assert.Equal(expected, formatted); + } + } + + // Failing tests - non-array inputs + [Theory] + [InlineData(":42\r\n")] + [InlineData("$3\r\nfoo\r\n")] + [InlineData("+OK\r\n")] + public void FailingHashEntryArray(string resp) => ExecuteUnexpected(resp, ResultProcessor.HashEntryArray); + + [Theory] + [InlineData(":42\r\n")] + [InlineData("$3\r\nfoo\r\n")] + [InlineData("+OK\r\n")] + public void FailingSortedSetEntryArray(string resp) => ExecuteUnexpected(resp, ResultProcessor.SortedSetWithScores); + + // Malformed jagged arrays (inner arrays not exactly length 2) + // Uniform odd counts: IsAllJaggedPairsReader returns false, falls back to interleaved, processes even pairs (discards odd element) + // Mixed lengths: IsAllJaggedPairsReader returns false, falls back to interleaved, throws when trying to read arrays as scalars + + // HashEntry tests that succeed with malformed data (uniform odd counts - not detected as jagged, processes as interleaved) + [Theory] + [InlineData("*1\r\n*1\r\n$1\r\na\r\n", RedisProtocol.Resp3)] // Inner array has 1 element - uniform, processes 0 pairs (1 >> 1 = 0) + [InlineData("*1\r\n*3\r\n$1\r\na\r\n$1\r\nb\r\n$1\r\nc\r\n", RedisProtocol.Resp3)] // Inner array has 3 elements - uniform, processes 1 pair (3 >> 1 = 1), discards 'c' + public void HashEntryArrayMalformedJaggedSucceeds(string resp, RedisProtocol protocol) + { + var result = Execute(resp, ResultProcessor.HashEntryArray, protocol: protocol); + Log($"Malformed jagged (uniform) result: {(result == null ? "null" : string.Join(",", result.Select(static e => $"{e.Name}={e.Value}")))}"); + } + + // HashEntry tests that throw (mixed lengths - fallback to interleaved tries to read arrays as scalars) + [Theory] + [InlineData("*2\r\n*2\r\n$1\r\na\r\n$1\r\nb\r\n*1\r\n$1\r\nc\r\n", RedisProtocol.Resp3)] // Mixed: first has 2, second has 1 + [InlineData("*2\r\n*2\r\n$1\r\na\r\n$1\r\nb\r\n*3\r\n$1\r\nc\r\n$1\r\nd\r\n$1\r\ne\r\n", RedisProtocol.Resp3)] // Mixed: first has 2, second has 3 + public void HashEntryArrayMalformedJaggedThrows(string resp, RedisProtocol protocol) + { + var ex = Assert.Throws(() => Execute(resp, ResultProcessor.HashEntryArray, protocol: protocol)); + Log($"Malformed jagged threw: {ex.GetType().Name}: {ex.Message}"); + } + + // SortedSetEntry tests that succeed with malformed data (uniform odd counts - not detected as jagged, processes as interleaved) + [Theory] + [InlineData("*1\r\n*1\r\n$1\r\na\r\n", RedisProtocol.Resp3)] // Inner array has 1 element - uniform, processes 0 pairs (1 >> 1 = 0) + [InlineData("*1\r\n*3\r\n$1\r\na\r\n,1.0\r\n$1\r\nb\r\n", RedisProtocol.Resp3)] // Inner array has 3 elements - uniform, processes 1 pair (3 >> 1 = 1), discards 'b' + public void SortedSetEntryArrayMalformedJaggedSucceeds(string resp, RedisProtocol protocol) + { + var result = Execute(resp, ResultProcessor.SortedSetWithScores, protocol: protocol); + Log($"Malformed jagged (uniform) result: {(result == null ? "null" : string.Join(",", result.Select(static e => $"{e.Element}:{e.Score}")))}"); + } + + // SortedSetEntry tests that throw (mixed lengths - fallback to interleaved tries to read arrays as scalars) + [Theory] + [InlineData("*2\r\n*2\r\n$1\r\na\r\n,1.0\r\n*1\r\n$1\r\nb\r\n", RedisProtocol.Resp3)] // Mixed: first has 2, second has 1 + [InlineData("*2\r\n*2\r\n$1\r\na\r\n,1.0\r\n*3\r\n$1\r\nb\r\n,2.0\r\n$1\r\nc\r\n", RedisProtocol.Resp3)] // Mixed: first has 2, second has 3 + public void SortedSetEntryArrayMalformedJaggedThrows(string resp, RedisProtocol protocol) + { + var ex = Assert.Throws(() => Execute(resp, ResultProcessor.SortedSetWithScores, protocol: protocol)); + Log($"Malformed jagged threw: {ex.GetType().Name}: {ex.Message}"); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Latency.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Latency.cs new file mode 100644 index 000000000..724e44417 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Latency.cs @@ -0,0 +1,96 @@ +using System; +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +/// +/// Tests for Latency result processors. +/// +public class Latency(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Theory] + [InlineData("*0\r\n", 0)] // empty array + [InlineData("*1\r\n*4\r\n$7\r\ncommand\r\n:1405067976\r\n:251\r\n:1001\r\n", 1)] // single entry + [InlineData("*2\r\n*4\r\n$7\r\ncommand\r\n:1405067976\r\n:251\r\n:1001\r\n*4\r\n$4\r\nfast\r\n:1405067980\r\n:100\r\n:500\r\n", 2)] // two entries + public void LatencyLatestEntry_ValidInput(string resp, int expectedCount) + { + var processor = LatencyLatestEntry.ToArray; + var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(expectedCount, result.Length); + } + + [Fact] + public void LatencyLatestEntry_ValidatesContent() + { + // Single entry: ["command", 1405067976, 251, 1001] + var resp = "*1\r\n*4\r\n$7\r\ncommand\r\n:1405067976\r\n:251\r\n:1001\r\n"; + var processor = LatencyLatestEntry.ToArray; + var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Single(result); + + var entry = result[0]; + Assert.Equal("command", entry.EventName); + Assert.Equal(RedisBase.UnixEpoch.AddSeconds(1405067976), entry.Timestamp); + Assert.Equal(251, entry.DurationMilliseconds); + Assert.Equal(1001, entry.MaxDurationMilliseconds); + } + + [Theory] + [InlineData("*-1\r\n")] // null array (RESP2) + [InlineData("_\r\n")] // null (RESP3) + public void LatencyLatestEntry_NullArray(string resp) + { + var processor = LatencyLatestEntry.ToArray; + var result = Execute(resp, processor); + + Assert.Null(result); + } + + [Theory] + [InlineData("*0\r\n", 0)] // empty array + [InlineData("*1\r\n*2\r\n:1405067822\r\n:251\r\n", 1)] // single entry + [InlineData("*2\r\n*2\r\n:1405067822\r\n:251\r\n*2\r\n:1405067941\r\n:1001\r\n", 2)] // two entries (from redis-cli example) + public void LatencyHistoryEntry_ValidInput(string resp, int expectedCount) + { + var processor = LatencyHistoryEntry.ToArray; + var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(expectedCount, result.Length); + } + + [Fact] + public void LatencyHistoryEntry_ValidatesContent() + { + // Two entries from redis-cli example + var resp = "*2\r\n*2\r\n:1405067822\r\n:251\r\n*2\r\n:1405067941\r\n:1001\r\n"; + var processor = LatencyHistoryEntry.ToArray; + var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(2, result.Length); + + var entry1 = result[0]; + Assert.Equal(RedisBase.UnixEpoch.AddSeconds(1405067822), entry1.Timestamp); + Assert.Equal(251, entry1.DurationMilliseconds); + + var entry2 = result[1]; + Assert.Equal(RedisBase.UnixEpoch.AddSeconds(1405067941), entry2.Timestamp); + Assert.Equal(1001, entry2.DurationMilliseconds); + } + + [Theory] + [InlineData("*-1\r\n")] // null array (RESP2) + [InlineData("_\r\n")] // null (RESP3) + public void LatencyHistoryEntry_NullArray(string resp) + { + var processor = LatencyHistoryEntry.ToArray; + var result = Execute(resp, processor); + + Assert.Null(result); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Lease.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Lease.cs new file mode 100644 index 000000000..356d583e9 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Lease.cs @@ -0,0 +1,100 @@ +using System; +using System.Text; +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +/// +/// Tests for Lease result processors. +/// +public class Lease(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Theory] + [InlineData("*0\r\n", 0)] // empty array + [InlineData("*3\r\n,1.5\r\n,2.5\r\n,3.5\r\n", 3)] // 3 floats + [InlineData("*2\r\n:1\r\n:2\r\n", 2)] // integers converted to floats + [InlineData("*1\r\n$3\r\n1.5\r\n", 1)] // bulk string converted to float + [InlineData("*?\r\n,1.5\r\n,2.5\r\n,3.5\r\n.\r\n", 3)] // streaming aggregate with 3 floats + [InlineData("*?\r\n.\r\n", 0)] // streaming empty array + public void LeaseFloat32Processor_ValidInput(string resp, int expectedCount) + { + var processor = ResultProcessor.LeaseFloat32; + using var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(expectedCount, result.Length); + } + + [Fact] + public void LeaseFloat32Processor_ValidatesContent() + { + // Array of 3 floats: 1.5, 2.5, 3.5 + var resp = "*3\r\n,1.5\r\n,2.5\r\n,3.5\r\n"; + var processor = ResultProcessor.LeaseFloat32; + using var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(3, result.Length); + Assert.Equal(1.5f, result.Span[0]); + Assert.Equal(2.5f, result.Span[1]); + Assert.Equal(3.5f, result.Span[2]); + } + + [Theory] + [InlineData("*-1\r\n")] // null array (RESP2) + [InlineData("_\r\n")] // null (RESP3) + public void LeaseFloat32Processor_NullArray(string resp) + { + var processor = ResultProcessor.LeaseFloat32; + var result = Execute(resp, processor); + + Assert.Null(result); + } + + [Theory] + [InlineData("$5\r\nhello\r\n")] // scalar string (not an array) + [InlineData(":42\r\n")] // scalar integer (not an array) + public void LeaseFloat32Processor_InvalidInput(string resp) + { + var processor = ResultProcessor.LeaseFloat32; + ExecuteUnexpected(resp, processor); + } + + [Theory] + [InlineData("$5\r\nhello\r\n", "hello")] // bulk string + [InlineData("+world\r\n", "world")] // simple string + [InlineData(":42\r\n", "42")] // integer + public void LeaseProcessor_ValidInput(string resp, string expected) + { + var processor = ResultProcessor.Lease; + using var result = Execute(resp, processor); + + Assert.NotNull(result); + + var str = Encoding.UTF8.GetString(result.Span); + Assert.Equal(expected, str); + } + + [Theory] + [InlineData("*1\r\n$5\r\nhello\r\n", "hello")] // array of 1 bulk string + [InlineData("*1\r\n+world\r\n", "world")] // array of 1 simple string + public void LeaseFromArrayProcessor_ValidInput(string resp, string expected) + { + var processor = ResultProcessor.LeaseFromArray; + using var result = Execute(resp, processor); + + Assert.NotNull(result); + + var str = Encoding.UTF8.GetString(result.Span); + Assert.Equal(expected, str); + } + + [Theory] + [InlineData("*0\r\n")] // empty array + [InlineData("*2\r\n$5\r\nhello\r\n$5\r\nworld\r\n")] // array of 2 (not 1) + public void LeaseFromArrayProcessor_InvalidInput(string resp) + { + var processor = ResultProcessor.LeaseFromArray; + ExecuteUnexpected(resp, processor); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/LeaseRedisValue.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/LeaseRedisValue.cs new file mode 100644 index 000000000..329d8890e --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/LeaseRedisValue.cs @@ -0,0 +1,89 @@ +using System; +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +/// +/// Tests for LeaseRedisValue result processor. +/// +public class LeaseRedisValue(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Theory] + [InlineData("*0\r\n", 0)] // empty array (key doesn't exist) + [InlineData("*1\r\n$5\r\nhello\r\n", 1)] // array with 1 element + [InlineData("*3\r\n$3\r\naaa\r\n$3\r\nbbb\r\n$3\r\nccc\r\n", 3)] // array with 3 elements in lexicographical order + [InlineData("*2\r\n$4\r\ntest\r\n$5\r\nvalue\r\n", 2)] // array with 2 elements + [InlineData("*?\r\n$5\r\nhello\r\n$5\r\nworld\r\n.\r\n", 2)] // streaming aggregate with 2 elements + [InlineData("*?\r\n.\r\n", 0)] // streaming empty array + public void LeaseRedisValueProcessor_ValidInput(string resp, int expectedCount) + { + var processor = ResultProcessor.LeaseRedisValue; + using var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(expectedCount, result.Length); + } + + [Fact] + public void LeaseRedisValueProcessor_ValidatesContent() + { + // Array of 3 RedisValues: "aaa", "bbb", "ccc" + var resp = "*3\r\n$3\r\naaa\r\n$3\r\nbbb\r\n$3\r\nccc\r\n"; + var processor = ResultProcessor.LeaseRedisValue; + using var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(3, result.Length); + Assert.Equal("aaa", result.Span[0].ToString()); + Assert.Equal("bbb", result.Span[1].ToString()); + Assert.Equal("ccc", result.Span[2].ToString()); + } + + [Fact] + public void LeaseRedisValueProcessor_EmptyArray() + { + // Empty array (key doesn't exist) + var resp = "*0\r\n"; + var processor = ResultProcessor.LeaseRedisValue; + using var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(0, result.Length); + } + + [Theory] + [InlineData("*-1\r\n")] // null array (RESP2) + [InlineData("_\r\n")] // null (RESP3) + public void LeaseRedisValueProcessor_NullArray(string resp) + { + var processor = ResultProcessor.LeaseRedisValue; + var result = Execute(resp, processor); + + Assert.Null(result); + } + + [Theory] + [InlineData("$5\r\nhello\r\n")] // scalar string (not an array) + [InlineData(":42\r\n")] // scalar integer (not an array) + [InlineData("+OK\r\n")] // simple string (not an array) + public void LeaseRedisValueProcessor_InvalidInput(string resp) + { + var processor = ResultProcessor.LeaseRedisValue; + ExecuteUnexpected(resp, processor); + } + + [Fact] + public void LeaseRedisValueProcessor_MixedTypes() + { + // Array with mixed types: bulk string, simple string, integer + var resp = "*3\r\n$5\r\nhello\r\n+world\r\n:42\r\n"; + var processor = ResultProcessor.LeaseRedisValue; + using var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(3, result.Length); + Assert.Equal("hello", result.Span[0].ToString()); + Assert.Equal("world", result.Span[1].ToString()); + Assert.Equal("42", result.Span[2].ToString()); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/LongestCommonSubsequence.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/LongestCommonSubsequence.cs new file mode 100644 index 000000000..46e7d15aa --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/LongestCommonSubsequence.cs @@ -0,0 +1,105 @@ +using System.Linq; +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class LongestCommonSubsequence(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Fact] + public void SingleMatch_Success() + { + // LCS key1 key2 IDX MINMATCHLEN 4 WITHMATCHLEN + // 1) "matches" + // 2) 1) 1) 1) (integer) 4 + // 2) (integer) 7 + // 2) 1) (integer) 5 + // 2) (integer) 8 + // 3) (integer) 4 + // 3) "len" + // 4) (integer) 6 + var resp = "*4\r\n$7\r\nmatches\r\n*1\r\n*3\r\n*2\r\n:4\r\n:7\r\n*2\r\n:5\r\n:8\r\n:4\r\n$3\r\nlen\r\n:6\r\n"; + var result = Execute(resp, ResultProcessor.LCSMatchResult); + + Assert.Equal(6, result.LongestMatchLength); + Assert.Single(result.Matches); + + // Verify backward-compatible properties + Assert.Equal(4, result.Matches[0].FirstStringIndex); + Assert.Equal(5, result.Matches[0].SecondStringIndex); + Assert.Equal(4, result.Matches[0].Length); + + // Verify new Position properties + Assert.Equal(4, result.Matches[0].First.Start); + Assert.Equal(7, result.Matches[0].First.End); + Assert.Equal(5, result.Matches[0].Second.Start); + Assert.Equal(8, result.Matches[0].Second.End); + } + + [Fact] + public void TwoMatches_Success() + { + // LCS key1 key2 IDX MINMATCHLEN 0 WITHMATCHLEN + // 1) "matches" + // 2) 1) 1) 1) (integer) 4 + // 2) (integer) 7 + // 2) 1) (integer) 5 + // 2) (integer) 8 + // 3) (integer) 4 + // 2) 1) 1) (integer) 2 + // 2) (integer) 3 + // 2) 1) (integer) 0 + // 2) (integer) 1 + // 3) (integer) 2 + // 3) "len" + // 4) (integer) 6 + var resp = "*4\r\n$7\r\nmatches\r\n*2\r\n*3\r\n*2\r\n:4\r\n:7\r\n*2\r\n:5\r\n:8\r\n:4\r\n*3\r\n*2\r\n:2\r\n:3\r\n*2\r\n:0\r\n:1\r\n:2\r\n$3\r\nlen\r\n:6\r\n"; + var result = Execute(resp, ResultProcessor.LCSMatchResult); + + Assert.Equal(6, result.LongestMatchLength); + Assert.Equal(2, result.Matches.Length); + + // First match - verify backward-compatible properties + Assert.Equal(4, result.Matches[0].FirstStringIndex); + Assert.Equal(5, result.Matches[0].SecondStringIndex); + Assert.Equal(4, result.Matches[0].Length); + + // First match - verify new Position properties + Assert.Equal(4, result.Matches[0].First.Start); + Assert.Equal(7, result.Matches[0].First.End); + Assert.Equal(5, result.Matches[0].Second.Start); + Assert.Equal(8, result.Matches[0].Second.End); + + // Second match - verify backward-compatible properties + Assert.Equal(2, result.Matches[1].FirstStringIndex); + Assert.Equal(0, result.Matches[1].SecondStringIndex); + Assert.Equal(2, result.Matches[1].Length); + + // Second match - verify new Position properties + Assert.Equal(2, result.Matches[1].First.Start); + Assert.Equal(3, result.Matches[1].First.End); + Assert.Equal(0, result.Matches[1].Second.Start); + Assert.Equal(1, result.Matches[1].Second.End); + } + + [Fact] + public void NoMatches_Success() + { + // LCS key1 key2 IDX + // 1) "matches" + // 2) (empty array) + // 3) "len" + // 4) (integer) 0 + var resp = "*4\r\n$7\r\nmatches\r\n*0\r\n$3\r\nlen\r\n:0\r\n"; + var result = Execute(resp, ResultProcessor.LCSMatchResult); + + Assert.Equal(0, result.LongestMatchLength); + Assert.Empty(result.Matches); + } + + [Fact] + public void NotArray_Failure() + { + var resp = "+OK\r\n"; + ExecuteUnexpected(resp, ResultProcessor.LCSMatchResult); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Misc.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Misc.cs new file mode 100644 index 000000000..1794cc2ed --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Misc.cs @@ -0,0 +1,92 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class Misc(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Theory] + [InlineData(":1\r\n", StreamTrimResult.Deleted)] // Integer 1 + [InlineData(":-1\r\n", StreamTrimResult.NotFound)] // Integer -1 + [InlineData(":2\r\n", StreamTrimResult.NotDeleted)] // Integer 2 + [InlineData("+1\r\n", StreamTrimResult.Deleted)] // Simple string "1" + [InlineData("$1\r\n1\r\n", StreamTrimResult.Deleted)] // Bulk string "1" + [InlineData("*1\r\n:1\r\n", StreamTrimResult.Deleted)] // Unit array with integer 1 + public void Int32EnumProcessor_StreamTrimResult(string resp, StreamTrimResult expected) + { + var processor = ResultProcessor.StreamTrimResult; + var result = Execute(resp, processor); + Assert.Equal(expected, result); + } + + [Fact] + public void Int32EnumArrayProcessor_StreamTrimResult_EmptyArray() + { + var resp = "*0\r\n"; + var processor = ResultProcessor.StreamTrimResultArray; + var result = Execute(resp, processor); + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void Int32EnumArrayProcessor_StreamTrimResult_NullArray() + { + var resp = "*-1\r\n"; + var processor = ResultProcessor.StreamTrimResultArray; + var result = Execute(resp, processor); + Assert.Null(result); + } + + [Fact] + public void Int32EnumArrayProcessor_StreamTrimResult_MultipleValues() + { + // Array with 3 elements: [1, -1, 2] + var resp = "*3\r\n:1\r\n:-1\r\n:2\r\n"; + var processor = ResultProcessor.StreamTrimResultArray; + var result = Execute(resp, processor); + Assert.NotNull(result); + Assert.Equal(3, result.Length); + Assert.Equal(StreamTrimResult.Deleted, result[0]); + Assert.Equal(StreamTrimResult.NotFound, result[1]); + Assert.Equal(StreamTrimResult.NotDeleted, result[2]); + } + + [Fact] + public void ConnectionIdentityProcessor_ReturnsEndPoint() + { + // ConnectionIdentityProcessor doesn't actually read from the RESP response, + // it just returns the endpoint from the connection (or null if no bridge). + var resp = "+OK\r\n"; + var processor = ResultProcessor.ConnectionIdentity; + var result = Execute(resp, processor); + + // No bridge in test helper means result is null, but that's OK + Assert.Null(result); + } + + [Fact] + public void DigestProcessor_ValidDigest() + { + // DigestProcessor reads a scalar string containing a hex digest + // Example: XXh3 digest of "asdfasd" is "91d2544ff57ccca3" + var resp = "$16\r\n91d2544ff57ccca3\r\n"; + var processor = ResultProcessor.Digest; + var result = Execute(resp, processor); + Assert.NotNull(result); + Assert.True(result.HasValue); + + // Parse the expected digest and verify equality + var expected = ValueCondition.ParseDigest("91d2544ff57ccca3"); + Assert.Equal(expected, result.Value); + } + + [Fact] + public void DigestProcessor_NullDigest() + { + // DigestProcessor should handle null responses + var resp = "$-1\r\n"; + var processor = ResultProcessor.Digest; + var result = Execute(resp, processor); + Assert.Null(result); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/RedisValueFromArray.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/RedisValueFromArray.cs new file mode 100644 index 000000000..7966a9a7c --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/RedisValueFromArray.cs @@ -0,0 +1,34 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class RedisValueFromArray(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Fact] + public void SingleElementArray_String() + { + var result = Execute("*1\r\n$5\r\nhello\r\n", ResultProcessor.RedisValueFromArray); + Assert.Equal("hello", (string?)result); + } + + [Fact] + public void SingleElementArray_Integer() + { + var result = Execute("*1\r\n:42\r\n", ResultProcessor.RedisValueFromArray); + Assert.Equal(42, (long)result); + } + + [Fact] + public void SingleElementArray_Null() + { + var result = Execute("*1\r\n$-1\r\n", ResultProcessor.RedisValueFromArray); + Assert.True(result.IsNull); + } + + [Fact] + public void SingleElementArray_EmptyString() + { + var result = Execute("*1\r\n$0\r\n\r\n", ResultProcessor.RedisValueFromArray); + Assert.Equal("", (string?)result); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/ResultProcessorUnitTest.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/ResultProcessorUnitTest.cs new file mode 100644 index 000000000..a0f378b68 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/ResultProcessorUnitTest.cs @@ -0,0 +1,91 @@ +using System; +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Text; +using RESPite.Messages; +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +/// +/// Base class for ResultProcessor unit tests. +/// Tests are organized into subclass files by category. +/// +public abstract class ResultProcessorUnitTest(ITestOutputHelper log) +{ + private protected const string ATTRIB_FOO_BAR = "|1\r\n+foo\r\n+bar\r\n"; + private protected static readonly ResultProcessor.Int64DefaultValueProcessor Int64DefaultValue999 = new(999); + + [return: NotNullIfNotNull(nameof(array))] + protected static string? Join(T[]? array, string separator = ",") + { + if (array is null) return null; + return string.Join(separator, array); + } + + public void Log(string message) => log?.WriteLine(message); + + private protected static Message DummyMessage() + => Message.Create(0, default, RedisCommand.UNKNOWN); + + private protected void ExecuteUnexpected( + string resp, + ResultProcessor processor, + Message? message = null, + ConnectionType connectionType = ConnectionType.Interactive, + RedisProtocol protocol = RedisProtocol.Resp2, + [CallerMemberName] string caller = "") + { + Assert.False(TryExecute(resp, processor, out _, out var ex, message, connectionType, protocol, caller), caller); + if (ex is not null) Log(ex.Message); + Assert.StartsWith("Unexpected response to UNKNOWN:", Assert.IsType(ex).Message); + } + private protected static T? Execute( + string resp, + ResultProcessor processor, + Message? message = null, + ConnectionType connectionType = ConnectionType.Interactive, + RedisProtocol protocol = RedisProtocol.Resp2, + [CallerMemberName] string caller = "") + { + Assert.True(TryExecute(resp, processor, out var value, out var ex, message, connectionType, protocol, caller)); + Assert.Null(ex); + return value; + } + + private protected static bool TryExecute( + string resp, + ResultProcessor processor, + out T? value, + out Exception? exception, + Message? message = null, + ConnectionType connectionType = ConnectionType.Interactive, + RedisProtocol protocol = RedisProtocol.Resp2, + [CallerMemberName] string caller = "") + { + byte[]? lease = null; + try + { + var maxLen = Encoding.UTF8.GetMaxByteCount(resp.Length); + const int MAX_STACK = 128; + Span oversized = maxLen <= MAX_STACK + ? stackalloc byte[MAX_STACK] + : (lease = ArrayPool.Shared.Rent(maxLen)); + + message ??= DummyMessage(); + var box = SimpleResultBox.Get(); + message.SetSource(processor, box); + + var reader = new RespReader(oversized.Slice(0, Encoding.UTF8.GetBytes(resp, oversized))); + PhysicalConnection connection = new(connectionType, protocol, name: caller); + Assert.True(processor.SetResult(connection, message, ref reader)); + value = box.GetResult(out exception, canRecycle: true); + return exception is null; + } + finally + { + if (lease is not null) ArrayPool.Shared.Return(lease); + } + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Role.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Role.cs new file mode 100644 index 000000000..943a444e2 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Role.cs @@ -0,0 +1,159 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class RoleTests(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Fact] + public void Role_Master_NoReplicas() + { + // 1) "master" + // 2) (integer) 3129659 + // 3) (empty array) + var resp = "*3\r\n$6\r\nmaster\r\n:3129659\r\n*0\r\n"; + var processor = ResultProcessor.Role; + var result = Execute(resp, processor); + + Assert.NotNull(result); + var master = Assert.IsType(result); + Assert.Equal("master", master.Value); + Assert.Equal(3129659, master.ReplicationOffset); + Assert.NotNull(master.Replicas); + Assert.Empty(master.Replicas); + } + + [Fact] + public void Role_Master_WithReplicas() + { + // 1) "master" + // 2) (integer) 3129659 + // 3) 1) 1) "127.0.0.1" + // 2) "9001" + // 3) "3129242" + // 2) 1) "127.0.0.1" + // 2) "9002" + // 3) "3129543" + var resp = "*3\r\n" + + "$6\r\nmaster\r\n" + + ":3129659\r\n" + + "*2\r\n" + + "*3\r\n$9\r\n127.0.0.1\r\n$4\r\n9001\r\n$7\r\n3129242\r\n" + + "*3\r\n$9\r\n127.0.0.1\r\n$4\r\n9002\r\n$7\r\n3129543\r\n"; + var processor = ResultProcessor.Role; + var result = Execute(resp, processor); + + Assert.NotNull(result); + var master = Assert.IsType(result); + Assert.Equal("master", master.Value); + Assert.Equal(3129659, master.ReplicationOffset); + Assert.NotNull(master.Replicas); + Assert.Equal(2, master.Replicas.Count); + + var replicas = new System.Collections.Generic.List(master.Replicas); + Assert.Equal("127.0.0.1", replicas[0].Ip); + Assert.Equal(9001, replicas[0].Port); + Assert.Equal(3129242, replicas[0].ReplicationOffset); + + Assert.Equal("127.0.0.1", replicas[1].Ip); + Assert.Equal(9002, replicas[1].Port); + Assert.Equal(3129543, replicas[1].ReplicationOffset); + } + + [Theory] + [InlineData("slave")] + [InlineData("replica")] + public void Role_Replica_Connected(string roleType) + { + // 1) "slave" (or "replica") + // 2) "127.0.0.1" + // 3) (integer) 9000 + // 4) "connected" + // 5) (integer) 3167038 + var resp = $"*5\r\n${roleType.Length}\r\n{roleType}\r\n$9\r\n127.0.0.1\r\n:9000\r\n$9\r\nconnected\r\n:3167038\r\n"; + var processor = ResultProcessor.Role; + var result = Execute(resp, processor); + + Assert.NotNull(result); + var replica = Assert.IsType(result); + Assert.Equal(roleType, replica.Value); + Assert.Equal("127.0.0.1", replica.MasterIp); + Assert.Equal(9000, replica.MasterPort); + Assert.Equal("connected", replica.State); + Assert.Equal(3167038, replica.ReplicationOffset); + } + + [Theory] + [InlineData("connect")] + [InlineData("connecting")] + [InlineData("sync")] + [InlineData("connected")] + [InlineData("none")] + [InlineData("handshake")] + public void Role_Replica_VariousStates(string state) + { + var resp = $"*5\r\n$5\r\nslave\r\n$9\r\n127.0.0.1\r\n:9000\r\n${state.Length}\r\n{state}\r\n:3167038\r\n"; + var processor = ResultProcessor.Role; + var result = Execute(resp, processor); + + Assert.NotNull(result); + var replica = Assert.IsType(result); + Assert.Equal(state, replica.State); + } + + [Fact] + public void Role_Sentinel() + { + // 1) "sentinel" + // 2) 1) "resque-master" + // 2) "html-fragments-master" + // 3) "stats-master" + // 4) "metadata-master" + var resp = "*2\r\n" + + "$8\r\nsentinel\r\n" + + "*4\r\n" + + "$13\r\nresque-master\r\n" + + "$21\r\nhtml-fragments-master\r\n" + + "$12\r\nstats-master\r\n" + + "$15\r\nmetadata-master\r\n"; + var processor = ResultProcessor.Role; + var result = Execute(resp, processor); + + Assert.NotNull(result); + var sentinel = Assert.IsType(result); + Assert.Equal("sentinel", sentinel.Value); + Assert.NotNull(sentinel.MonitoredMasters); + Assert.Equal(4, sentinel.MonitoredMasters.Count); + + var masters = new System.Collections.Generic.List(sentinel.MonitoredMasters); + Assert.Equal("resque-master", masters[0]); + Assert.Equal("html-fragments-master", masters[1]); + Assert.Equal("stats-master", masters[2]); + Assert.Equal("metadata-master", masters[3]); + } + + [Theory] + [InlineData("unknown", false)] // Short value - tests TryGetSpan path + [InlineData("unknown", true)] + [InlineData("long_value_to_test_buffer_size", true)] // Streaming scalar - tests Buffer path (TryGetSpan fails on non-contiguous) + public void Role_Unknown(string roleName, bool streaming) + { + var resp = streaming + ? $"*1\r\n$?\r\n;{roleName.Length}\r\n{roleName}\r\n;6\r\n_extra\r\n;0\r\n" // force an extra chunk + : $"*1\r\n${roleName.Length}\r\n{roleName}\r\n"; + var processor = ResultProcessor.Role; + var result = Execute(resp, processor); + + Assert.NotNull(result); + var unknown = Assert.IsType(result); + Assert.Equal(roleName + (streaming ? "_extra" : ""), unknown.Value); + } + + [Fact] + public void Role_EmptyArray_ReturnsNull() + { + var resp = "*0\r\n"; + var processor = ResultProcessor.Role; + var result = Execute(resp, processor); + Assert.Null(result); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Scan.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Scan.cs new file mode 100644 index 000000000..feae87a42 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Scan.cs @@ -0,0 +1,132 @@ +using System; +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class Scan(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + // SCAN/SSCAN format: array of 2 elements [cursor, array of keys] + // Example: *2\r\n$1\r\n0\r\n*3\r\n$3\r\nkey1\r\n$3\r\nkey2\r\n$3\r\nkey3\r\n + [Theory] + [InlineData("*2\r\n$1\r\n0\r\n*0\r\n", 0L, 0)] // cursor 0, empty array + [InlineData("*2\r\n$1\r\n5\r\n*0\r\n", 5L, 0)] // cursor 5, empty array + [InlineData("*2\r\n$1\r\n0\r\n*1\r\n$3\r\nfoo\r\n", 0L, 1)] // cursor 0, 1 key + [InlineData("*2\r\n$1\r\n0\r\n*3\r\n$4\r\nkey1\r\n$4\r\nkey2\r\n$4\r\nkey3\r\n", 0L, 3)] // cursor 0, 3 keys + [InlineData("*2\r\n$2\r\n42\r\n*2\r\n$4\r\ntest\r\n$5\r\nhello\r\n", 42L, 2)] // cursor 42, 2 keys + public void SetScanResultProcessor_ValidInput(string resp, long expectedCursor, int expectedCount) + { + var processor = RedisDatabase.SetScanResultProcessor.Default; + var result = Execute(resp, processor); + + Assert.Equal(expectedCursor, result.Cursor); + Assert.Equal(expectedCount, result.Count); + } + + [Fact] + public void SetScanResultProcessor_ValidatesContent() + { + // cursor 0, 3 keys: "key1", "key2", "key3" + var resp = "*2\r\n$1\r\n0\r\n*3\r\n$4\r\nkey1\r\n$4\r\nkey2\r\n$4\r\nkey3\r\n"; + var processor = RedisDatabase.SetScanResultProcessor.Default; + var result = Execute(resp, processor); + + Assert.Equal(0L, result.Cursor); + Assert.Equal(3, result.Count); + + // Access the values through the result + var values = result.Values; + Assert.Equal(3, values.Length); + Assert.Equal("key1", (string?)values[0]); + Assert.Equal("key2", (string?)values[1]); + Assert.Equal("key3", (string?)values[2]); + + result.Recycle(); + } + + // HSCAN format: array of 2 elements [cursor, interleaved array of field/value pairs] + // Example: *2\r\n$1\r\n0\r\n*4\r\n$6\r\nfield1\r\n$6\r\nvalue1\r\n$6\r\nfield2\r\n$6\r\nvalue2\r\n + [Theory] + [InlineData("*2\r\n$1\r\n0\r\n*0\r\n", 0L, 0)] // cursor 0, empty array + [InlineData("*2\r\n$1\r\n7\r\n*0\r\n", 7L, 0)] // cursor 7, empty array + [InlineData("*2\r\n$1\r\n0\r\n*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n", 0L, 1)] // cursor 0, 1 pair + [InlineData("*2\r\n$1\r\n0\r\n*4\r\n$2\r\nf1\r\n$2\r\nv1\r\n$2\r\nf2\r\n$2\r\nv2\r\n", 0L, 2)] // cursor 0, 2 pairs + [InlineData("*2\r\n$2\r\n99\r\n*6\r\n$1\r\na\r\n$1\r\n1\r\n$1\r\nb\r\n$1\r\n2\r\n$1\r\nc\r\n$1\r\n3\r\n", 99L, 3)] // cursor 99, 3 pairs + public void HashScanResultProcessor_ValidInput(string resp, long expectedCursor, int expectedCount) + { + var processor = RedisDatabase.HashScanResultProcessor.Default; + var result = Execute(resp, processor); + + Assert.Equal(expectedCursor, result.Cursor); + Assert.Equal(expectedCount, result.Count); + } + + [Fact] + public void HashScanResultProcessor_ValidatesContent() + { + // cursor 0, 2 pairs: "field1"="value1", "field2"="value2" + var resp = "*2\r\n$1\r\n0\r\n*4\r\n$6\r\nfield1\r\n$6\r\nvalue1\r\n$6\r\nfield2\r\n$6\r\nvalue2\r\n"; + var processor = RedisDatabase.HashScanResultProcessor.Default; + var result = Execute(resp, processor); + + Assert.Equal(0L, result.Cursor); + Assert.Equal(2, result.Count); + + var entries = result.Values; + Assert.Equal(2, entries.Length); + Assert.Equal("field1", (string?)entries[0].Name); + Assert.Equal("value1", (string?)entries[0].Value); + Assert.Equal("field2", (string?)entries[1].Name); + Assert.Equal("value2", (string?)entries[1].Value); + + result.Recycle(); + } + + // ZSCAN format: array of 2 elements [cursor, interleaved array of member/score pairs] + // Example: *2\r\n$1\r\n0\r\n*4\r\n$7\r\nmember1\r\n$3\r\n1.5\r\n$7\r\nmember2\r\n$3\r\n2.5\r\n + [Theory] + [InlineData("*2\r\n$1\r\n0\r\n*0\r\n", 0L, 0)] // cursor 0, empty array + [InlineData("*2\r\n$2\r\n10\r\n*0\r\n", 10L, 0)] // cursor 10, empty array + [InlineData("*2\r\n$1\r\n0\r\n*2\r\n$3\r\nfoo\r\n$1\r\n1\r\n", 0L, 1)] // cursor 0, 1 pair + [InlineData("*2\r\n$1\r\n0\r\n*4\r\n$2\r\nm1\r\n$3\r\n1.5\r\n$2\r\nm2\r\n$3\r\n2.5\r\n", 0L, 2)] // cursor 0, 2 pairs + [InlineData("*2\r\n$2\r\n88\r\n*6\r\n$1\r\na\r\n$1\r\n1\r\n$1\r\nb\r\n$1\r\n2\r\n$1\r\nc\r\n$1\r\n3\r\n", 88L, 3)] // cursor 88, 3 pairs + public void SortedSetScanResultProcessor_ValidInput(string resp, long expectedCursor, int expectedCount) + { + var processor = RedisDatabase.SortedSetScanResultProcessor.Default; + var result = Execute(resp, processor); + + Assert.Equal(expectedCursor, result.Cursor); + Assert.Equal(expectedCount, result.Count); + } + + [Fact] + public void SortedSetScanResultProcessor_ValidatesContent() + { + // cursor 0, 2 pairs: "member1"=1.5, "member2"=2.5 + var resp = "*2\r\n$1\r\n0\r\n*4\r\n$7\r\nmember1\r\n$3\r\n1.5\r\n$7\r\nmember2\r\n$3\r\n2.5\r\n"; + var processor = RedisDatabase.SortedSetScanResultProcessor.Default; + var result = Execute(resp, processor); + + Assert.Equal(0L, result.Cursor); + Assert.Equal(2, result.Count); + + var entries = result.Values; + Assert.Equal(2, entries.Length); + Assert.Equal("member1", (string?)entries[0].Element); + Assert.Equal(1.5, entries[0].Score); + Assert.Equal("member2", (string?)entries[1].Element); + Assert.Equal(2.5, entries[1].Score); + + result.Recycle(); + } + + [Theory] + [InlineData("*1\r\n$1\r\n0\r\n")] // only 1 element instead of 2 + [InlineData("*3\r\n$1\r\n0\r\n*0\r\n$4\r\nextra\r\n")] // 3 elements instead of 2 + [InlineData("$1\r\n0\r\n")] // scalar instead of array + public void ScanProcessors_InvalidFormat(string resp) + { + ExecuteUnexpected(resp, RedisDatabase.SetScanResultProcessor.Default, caller: nameof(RedisDatabase.SetScanResultProcessor)); + ExecuteUnexpected(resp, RedisDatabase.HashScanResultProcessor.Default, caller: nameof(RedisDatabase.HashScanResultProcessor)); + ExecuteUnexpected(resp, RedisDatabase.SortedSetScanResultProcessor.Default, caller: nameof(RedisDatabase.SortedSetScanResultProcessor)); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/ScriptLoad.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/ScriptLoad.cs new file mode 100644 index 000000000..6940e3d45 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/ScriptLoad.cs @@ -0,0 +1,52 @@ +using System.Linq; +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +/// +/// Tests for ScriptLoadProcessor. +/// SCRIPT LOAD returns a bulk string containing the SHA1 hash (40 hex characters). +/// +public class ScriptLoad(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Theory] + [InlineData("$40\r\n829c3804401b0727f70f73d4415e162400cbe57b\r\n", "829c3804401b0727f70f73d4415e162400cbe57b")] + [InlineData("$40\r\n0000000000000000000000000000000000000000\r\n", "0000000000000000000000000000000000000000")] + [InlineData("$40\r\nffffffffffffffffffffffffffffffffffffffff\r\n", "ffffffffffffffffffffffffffffffffffffffff")] + [InlineData("$40\r\nABCDEF1234567890abcdef1234567890ABCDEF12\r\n", "ABCDEF1234567890abcdef1234567890ABCDEF12")] + public void ScriptLoadProcessor_ValidHash(string resp, string expectedAsciiHash) + { + var processor = ResultProcessor.ScriptLoad; + var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(20, result.Length); // SHA1 is 20 bytes + + // Convert the byte array back to hex string to verify + var actualHex = string.Concat(result.Select(b => b.ToString("x2"))); + Assert.Equal(expectedAsciiHash.ToLowerInvariant(), actualHex); + } + + [Theory] + [InlineData("$41\r\n829c3804401b0727f70f73d4415e162400cbe57bb\r\n")] // 41 chars instead of 40 + [InlineData("$0\r\n\r\n")] // empty string + [InlineData("$-1\r\n")] // null bulk string + [InlineData(":42\r\n")] // integer instead of bulk string + [InlineData("+OK\r\n")] // simple string instead of bulk string + [InlineData("*1\r\n$40\r\n829c3804401b0727f70f73d4415e162400cbe57b\r\n")] // array instead of bulk string + public void ScriptLoadProcessor_InvalidFormat(string resp) + { + var processor = ResultProcessor.ScriptLoad; + ExecuteUnexpected(resp, processor); + } + + [Theory] + [InlineData("$40\r\n829c3804401b0727f70f73d4415e162400cbe5zz\r\n")] // invalid hex chars (zz) + [InlineData("$40\r\n829c3804401b0727f70f73d4415e162400cbe5!!\r\n")] // invalid hex chars (!!) + [InlineData("$40\r\n829c3804401b0727f70f73d4415e162400cbe5 \r\n")] // spaces instead of hex + public void ScriptLoadProcessor_InvalidHexCharacters(string resp) + { + var processor = ResultProcessor.ScriptLoad; + ExecuteUnexpected(resp, processor); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/SentinelGetPrimaryAddressByName.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/SentinelGetPrimaryAddressByName.cs new file mode 100644 index 000000000..341e0c1ed --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/SentinelGetPrimaryAddressByName.cs @@ -0,0 +1,84 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class SentinelGetPrimaryAddressByName(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Fact] + public void ValidHostAndPort_Success() + { + // Array with 2 elements: host (bulk string) and port (integer) + var resp = "*2\r\n$9\r\n127.0.0.1\r\n:6379\r\n"; + var result = Execute(resp, ResultProcessor.SentinelPrimaryEndpoint); + + Assert.NotNull(result); + var ipEndpoint = Assert.IsType(result); + Assert.Equal("127.0.0.1", ipEndpoint.Address.ToString()); + Assert.Equal(6379, ipEndpoint.Port); + } + + [Fact] + public void DomainNameAndPort_Success() + { + // Array with 2 elements: domain name (bulk string) and port (integer) + var resp = "*2\r\n$17\r\nredis.example.com\r\n:6380\r\n"; + var result = Execute(resp, ResultProcessor.SentinelPrimaryEndpoint); + + Assert.NotNull(result); + var dnsEndpoint = Assert.IsType(result); + Assert.Equal("redis.example.com", dnsEndpoint.Host); + Assert.Equal(6380, dnsEndpoint.Port); + } + + [Fact] + public void NullArray_Success() + { + // Null array - primary doesn't exist + var resp = "*-1\r\n"; + var result = Execute(resp, ResultProcessor.SentinelPrimaryEndpoint); + + Assert.Null(result); + } + + [Fact] + public void EmptyArray_Success() + { + // Empty array - primary doesn't exist + var resp = "*0\r\n"; + var result = Execute(resp, ResultProcessor.SentinelPrimaryEndpoint); + + Assert.Null(result); + } + + [Fact] + public void NotArray_Failure() + { + // Simple string instead of array + var resp = "+OK\r\n"; + ExecuteUnexpected(resp, ResultProcessor.SentinelPrimaryEndpoint); + } + + [Fact] + public void ArrayWithOneElement_Failure() + { + // Array with only 1 element (missing port) + var resp = "*1\r\n$9\r\n127.0.0.1\r\n"; + ExecuteUnexpected(resp, ResultProcessor.SentinelPrimaryEndpoint); + } + + [Fact] + public void ArrayWithThreeElements_Failure() + { + // Array with 3 elements (too many) + var resp = "*3\r\n$9\r\n127.0.0.1\r\n:6379\r\n$5\r\nextra\r\n"; + ExecuteUnexpected(resp, ResultProcessor.SentinelPrimaryEndpoint); + } + + [Fact] + public void ArrayWithNonIntegerPort_Failure() + { + // Array with 2 elements but port is not an integer + var resp = "*2\r\n$9\r\n127.0.0.1\r\n$4\r\nport\r\n"; + ExecuteUnexpected(resp, ResultProcessor.SentinelPrimaryEndpoint); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/SentinelGetReplicaAddresses.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/SentinelGetReplicaAddresses.cs new file mode 100644 index 000000000..172c79322 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/SentinelGetReplicaAddresses.cs @@ -0,0 +1,88 @@ +using System.Net; +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class SentinelGetReplicaAddresses(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Fact] + public void SingleReplica_Success() + { + var resp = "*1\r\n*4\r\n$2\r\nip\r\n$9\r\n127.0.0.1\r\n$4\r\nport\r\n$4\r\n6380\r\n"; + var result = Execute(resp, ResultProcessor.SentinelReplicaEndPoints); + + Assert.NotNull(result); + Assert.Single(result); + + var endpoint = Assert.IsType(result[0]); + Assert.Equal("127.0.0.1", endpoint.Address.ToString()); + Assert.Equal(6380, endpoint.Port); + } + + [Fact] + public void MultipleReplicas_Success() + { + var resp = "*2\r\n*4\r\n$2\r\nip\r\n$9\r\n127.0.0.1\r\n$4\r\nport\r\n$4\r\n6380\r\n*4\r\n$2\r\nip\r\n$9\r\n127.0.0.2\r\n$4\r\nport\r\n$4\r\n6381\r\n"; + var result = Execute(resp, ResultProcessor.SentinelReplicaEndPoints); + + Assert.NotNull(result); + Assert.Equal(2, result.Length); + + var endpoint1 = Assert.IsType(result[0]); + Assert.Equal("127.0.0.1", endpoint1.Address.ToString()); + Assert.Equal(6380, endpoint1.Port); + + var endpoint2 = Assert.IsType(result[1]); + Assert.Equal("127.0.0.2", endpoint2.Address.ToString()); + Assert.Equal(6381, endpoint2.Port); + } + + [Fact] + public void DnsEndpoint_Success() + { + var resp = "*1\r\n*4\r\n$2\r\nip\r\n$17\r\nredis.example.com\r\n$4\r\nport\r\n$4\r\n6380\r\n"; + var result = Execute(resp, ResultProcessor.SentinelReplicaEndPoints); + + Assert.NotNull(result); + Assert.Single(result); + + var endpoint = Assert.IsType(result[0]); + Assert.Equal("redis.example.com", endpoint.Host); + Assert.Equal(6380, endpoint.Port); + } + + [Fact] + public void ReversedOrder_Success() + { + // Test that order doesn't matter - port before ip + var resp = "*1\r\n*4\r\n$4\r\nport\r\n$4\r\n6380\r\n$2\r\nip\r\n$9\r\n127.0.0.1\r\n"; + var result = Execute(resp, ResultProcessor.SentinelReplicaEndPoints); + + Assert.NotNull(result); + Assert.Single(result); + var endpoint = Assert.IsType(result[0]); + Assert.Equal("127.0.0.1", endpoint.Address.ToString()); + Assert.Equal(6380, endpoint.Port); + } + + [Fact] + public void EmptyArray_Failure() + { + var resp = "*0\r\n"; + ExecuteUnexpected(resp, ResultProcessor.SentinelReplicaEndPoints); + } + + [Fact] + public void NullArray_Failure() + { + var resp = "*-1\r\n"; + ExecuteUnexpected(resp, ResultProcessor.SentinelReplicaEndPoints); + } + + [Fact] + public void NotArray_Failure() + { + var resp = "+OK\r\n"; + ExecuteUnexpected(resp, ResultProcessor.SentinelReplicaEndPoints); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/SentinelGetSentinelAddresses.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/SentinelGetSentinelAddresses.cs new file mode 100644 index 000000000..98360d13a --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/SentinelGetSentinelAddresses.cs @@ -0,0 +1,90 @@ +using System.Net; +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class SentinelGetSentinelAddresses(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Fact] + public void SingleSentinel_Success() + { + var resp = "*1\r\n*4\r\n$2\r\nip\r\n$9\r\n127.0.0.1\r\n$4\r\nport\r\n$5\r\n26379\r\n"; + var result = Execute(resp, ResultProcessor.SentinelAddressesEndPoints); + + Assert.NotNull(result); + Assert.Single(result); + var endpoint = Assert.IsType(result[0]); + Assert.Equal("127.0.0.1", endpoint.Address.ToString()); + Assert.Equal(26379, endpoint.Port); + } + + [Fact] + public void MultipleSentinels_Success() + { + var resp = "*2\r\n*4\r\n$2\r\nip\r\n$9\r\n127.0.0.1\r\n$4\r\nport\r\n$5\r\n26379\r\n*4\r\n$2\r\nip\r\n$9\r\n127.0.0.2\r\n$4\r\nport\r\n$5\r\n26380\r\n"; + var result = Execute(resp, ResultProcessor.SentinelAddressesEndPoints); + + Assert.NotNull(result); + Assert.Equal(2, result.Length); + + var endpoint1 = Assert.IsType(result[0]); + Assert.Equal("127.0.0.1", endpoint1.Address.ToString()); + Assert.Equal(26379, endpoint1.Port); + + var endpoint2 = Assert.IsType(result[1]); + Assert.Equal("127.0.0.2", endpoint2.Address.ToString()); + Assert.Equal(26380, endpoint2.Port); + } + + [Fact] + public void DnsEndpoint_Success() + { + var resp = "*1\r\n*4\r\n$2\r\nip\r\n$20\r\nsentinel.example.com\r\n$4\r\nport\r\n$5\r\n26379\r\n"; + var result = Execute(resp, ResultProcessor.SentinelAddressesEndPoints); + + Assert.NotNull(result); + Assert.Single(result); + var endpoint = Assert.IsType(result[0]); + Assert.Equal("sentinel.example.com", endpoint.Host); + Assert.Equal(26379, endpoint.Port); + } + + [Fact] + public void ReversedOrder_Success() + { + var resp = "*1\r\n*4\r\n$4\r\nport\r\n$5\r\n26379\r\n$2\r\nip\r\n$9\r\n127.0.0.1\r\n"; + var result = Execute(resp, ResultProcessor.SentinelAddressesEndPoints); + + Assert.NotNull(result); + Assert.Single(result); + var endpoint = Assert.IsType(result[0]); + Assert.Equal("127.0.0.1", endpoint.Address.ToString()); + Assert.Equal(26379, endpoint.Port); + } + + [Fact] + public void EmptyArray_Success() + { + var resp = "*0\r\n"; + var result = Execute(resp, ResultProcessor.SentinelAddressesEndPoints); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void NullBulkString_Failure() + { + var resp = "$-1\r\n"; + var success = TryExecute(resp, ResultProcessor.SentinelAddressesEndPoints, out var result, out var exception); + + Assert.False(success); + } + + [Fact] + public void NotArray_Failure() + { + var resp = "+OK\r\n"; + ExecuteUnexpected(resp, ResultProcessor.SentinelAddressesEndPoints); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/StreamAutoClaim.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/StreamAutoClaim.cs new file mode 100644 index 000000000..7b80f4ad8 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/StreamAutoClaim.cs @@ -0,0 +1,122 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class StreamAutoClaim(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Fact] + public void WithEntries_ThreeElements_Success() + { + // XAUTOCLAIM mystream mygroup Alice 3600000 0-0 COUNT 25 + // 1) "0-0" + // 2) 1) 1) "1609338752495-0" + // 2) 1) "field" + // 2) "value" + // 3) (empty array) + var resp = "*3\r\n" + + "$3\r\n0-0\r\n" + + "*1\r\n" + // Array of 1 entry + "*2\r\n" + // Entry: [id, fields] + "$15\r\n1609338752495-0\r\n" + + "*2\r\n" + // Fields array + "$5\r\nfield\r\n" + + "$5\r\nvalue\r\n" + + "*0\r\n"; // Empty deleted IDs array + + var result = Execute(resp, ResultProcessor.StreamAutoClaim); + + Assert.Equal("0-0", result.NextStartId.ToString()); + Assert.Single(result.ClaimedEntries); + Assert.Equal("1609338752495-0", result.ClaimedEntries[0].Id.ToString()); + Assert.Equal("value", result.ClaimedEntries[0]["field"]); + Assert.Empty(result.DeletedIds); + } + + [Fact] + public void WithEntries_TwoElements_OlderServer_Success() + { + // Older Redis 6.2 - only returns 2 elements (no deleted IDs) + var resp = "*2\r\n" + + "$3\r\n0-0\r\n" + + "*1\r\n" + + "*2\r\n" + + "$15\r\n1609338752495-0\r\n" + + "*2\r\n" + + "$5\r\nfield\r\n" + + "$5\r\nvalue\r\n"; + + var result = Execute(resp, ResultProcessor.StreamAutoClaim); + + Assert.Equal("0-0", result.NextStartId.ToString()); + Assert.Single(result.ClaimedEntries); + Assert.Empty(result.DeletedIds); + } + + [Fact] + public void EmptyEntries_Success() + { + // No entries claimed + var resp = "*3\r\n" + + "$3\r\n0-0\r\n" + + "*0\r\n" + // Empty entries array + "*0\r\n"; // Empty deleted IDs array + + var result = Execute(resp, ResultProcessor.StreamAutoClaim); + + Assert.Equal("0-0", result.NextStartId.ToString()); + Assert.Empty(result.ClaimedEntries); + Assert.Empty(result.DeletedIds); + } + + [Fact] + public void NullEntries_Success() + { + // Null entries array (alternative representation) + var resp = "*3\r\n" + + "$3\r\n0-0\r\n" + + "$-1\r\n" + // Null entries + "*0\r\n"; + + var result = Execute(resp, ResultProcessor.StreamAutoClaim); + + Assert.Equal("0-0", result.NextStartId.ToString()); + Assert.Empty(result.ClaimedEntries); + Assert.Empty(result.DeletedIds); + } + + [Fact] + public void WithDeletedIds_Success() + { + // Some entries were deleted + var resp = "*3\r\n" + + "$3\r\n0-0\r\n" + + "*0\r\n" + // No claimed entries + "*2\r\n" + // 2 deleted IDs + "$15\r\n1609338752495-0\r\n" + + "$15\r\n1609338752496-0\r\n"; + + var result = Execute(resp, ResultProcessor.StreamAutoClaim); + + Assert.Equal("0-0", result.NextStartId.ToString()); + Assert.Empty(result.ClaimedEntries); + Assert.Equal(2, result.DeletedIds.Length); + Assert.Equal("1609338752495-0", result.DeletedIds[0].ToString()); + Assert.Equal("1609338752496-0", result.DeletedIds[1].ToString()); + } + + [Fact] + public void NotArray_Failure() + { + var resp = "$5\r\nhello\r\n"; + + ExecuteUnexpected(resp, ResultProcessor.StreamAutoClaim); + } + + [Fact] + public void Null_Failure() + { + var resp = "$-1\r\n"; + + ExecuteUnexpected(resp, ResultProcessor.StreamAutoClaim); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/StreamAutoClaimIdsOnly.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/StreamAutoClaimIdsOnly.cs new file mode 100644 index 000000000..355c8aa95 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/StreamAutoClaimIdsOnly.cs @@ -0,0 +1,117 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class StreamAutoClaimIdsOnly(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Fact] + public void WithIds_ThreeElements_Success() + { + // XAUTOCLAIM mystream mygroup Alice 3600000 0-0 COUNT 25 JUSTID + // 1) "0-0" + // 2) 1) "1609338752495-0" + // 2) "1609338752496-0" + // 3) (empty array) + var resp = "*3\r\n" + + "$3\r\n0-0\r\n" + + "*2\r\n" + // Array of 2 claimed IDs + "$15\r\n1609338752495-0\r\n" + + "$15\r\n1609338752496-0\r\n" + + "*0\r\n"; // Empty deleted IDs array + + var result = Execute(resp, ResultProcessor.StreamAutoClaimIdsOnly); + + Assert.Equal("0-0", result.NextStartId.ToString()); + Assert.Equal(2, result.ClaimedIds.Length); + Assert.Equal("1609338752495-0", result.ClaimedIds[0].ToString()); + Assert.Equal("1609338752496-0", result.ClaimedIds[1].ToString()); + Assert.Empty(result.DeletedIds); + } + + [Fact] + public void WithIds_TwoElements_OlderServer_Success() + { + // Older Redis 6.2 - only returns 2 elements (no deleted IDs) + var resp = "*2\r\n" + + "$3\r\n0-0\r\n" + + "*2\r\n" + + "$15\r\n1609338752495-0\r\n" + + "$15\r\n1609338752496-0\r\n"; + + var result = Execute(resp, ResultProcessor.StreamAutoClaimIdsOnly); + + Assert.Equal("0-0", result.NextStartId.ToString()); + Assert.Equal(2, result.ClaimedIds.Length); + Assert.Empty(result.DeletedIds); + } + + [Fact] + public void EmptyIds_Success() + { + // No IDs claimed + var resp = "*3\r\n" + + "$3\r\n0-0\r\n" + + "*0\r\n" + // Empty claimed IDs array + "*0\r\n"; // Empty deleted IDs array + + var result = Execute(resp, ResultProcessor.StreamAutoClaimIdsOnly); + + Assert.Equal("0-0", result.NextStartId.ToString()); + Assert.Empty(result.ClaimedIds); + Assert.Empty(result.DeletedIds); + } + + [Fact] + public void NullIds_Success() + { + // Null IDs array (alternative representation) + var resp = "*3\r\n" + + "$3\r\n0-0\r\n" + + "$-1\r\n" + // Null claimed IDs + "*0\r\n"; + + var result = Execute(resp, ResultProcessor.StreamAutoClaimIdsOnly); + + Assert.Equal("0-0", result.NextStartId.ToString()); + Assert.Empty(result.ClaimedIds); + Assert.Empty(result.DeletedIds); + } + + [Fact] + public void WithDeletedIds_Success() + { + // Some entries were deleted + var resp = "*3\r\n" + + "$3\r\n0-0\r\n" + + "*1\r\n" + // 1 claimed ID + "$15\r\n1609338752495-0\r\n" + + "*2\r\n" + // 2 deleted IDs + "$15\r\n1609338752496-0\r\n" + + "$15\r\n1609338752497-0\r\n"; + + var result = Execute(resp, ResultProcessor.StreamAutoClaimIdsOnly); + + Assert.Equal("0-0", result.NextStartId.ToString()); + Assert.Single(result.ClaimedIds); + Assert.Equal("1609338752495-0", result.ClaimedIds[0].ToString()); + Assert.Equal(2, result.DeletedIds.Length); + Assert.Equal("1609338752496-0", result.DeletedIds[0].ToString()); + Assert.Equal("1609338752497-0", result.DeletedIds[1].ToString()); + } + + [Fact] + public void NotArray_Failure() + { + var resp = "$5\r\nhello\r\n"; + + ExecuteUnexpected(resp, ResultProcessor.StreamAutoClaimIdsOnly); + } + + [Fact] + public void Null_Failure() + { + var resp = "$-1\r\n"; + + ExecuteUnexpected(resp, ResultProcessor.StreamAutoClaimIdsOnly); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/StreamInfo.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/StreamInfo.cs new file mode 100644 index 000000000..5b33f0563 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/StreamInfo.cs @@ -0,0 +1,123 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class StreamInfo(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Fact] + public void BasicFormat_Success() + { + // XINFO STREAM mystream (basic format, not FULL) + // Interleaved key-value array with entries like first-entry and last-entry as nested arrays + var resp = "*32\r\n" + + "$6\r\nlength\r\n" + + ":2\r\n" + + "$15\r\nradix-tree-keys\r\n" + + ":1\r\n" + + "$16\r\nradix-tree-nodes\r\n" + + ":2\r\n" + + "$17\r\nlast-generated-id\r\n" + + "$15\r\n1638125141232-0\r\n" + + "$20\r\nmax-deleted-entry-id\r\n" + + "$3\r\n0-0\r\n" + + "$13\r\nentries-added\r\n" + + ":2\r\n" + + "$23\r\nrecorded-first-entry-id\r\n" + + "$15\r\n1719505260513-0\r\n" + + "$13\r\nidmp-duration\r\n" + + ":100\r\n" + + "$12\r\nidmp-maxsize\r\n" + + ":100\r\n" + + "$12\r\npids-tracked\r\n" + + ":1\r\n" + + "$12\r\niids-tracked\r\n" + + ":1\r\n" + + "$10\r\niids-added\r\n" + + ":1\r\n" + + "$15\r\niids-duplicates\r\n" + + ":0\r\n" + + "$6\r\ngroups\r\n" + + ":1\r\n" + + "$11\r\nfirst-entry\r\n" + + "*2\r\n" + + "$15\r\n1638125133432-0\r\n" + + "*2\r\n" + + "$7\r\nmessage\r\n" + + "$5\r\napple\r\n" + + "$10\r\nlast-entry\r\n" + + "*2\r\n" + + "$15\r\n1638125141232-0\r\n" + + "*2\r\n" + + "$7\r\nmessage\r\n" + + "$6\r\nbanana\r\n"; + + var result = Execute(resp, ResultProcessor.StreamInfo); + + Assert.Equal(2, result.Length); + Assert.Equal(1, result.RadixTreeKeys); + Assert.Equal(2, result.RadixTreeNodes); + Assert.Equal(1, result.ConsumerGroupCount); + Assert.Equal("1638125141232-0", result.LastGeneratedId.ToString()); + Assert.Equal("0-0", result.MaxDeletedEntryId.ToString()); + Assert.Equal(2, result.EntriesAdded); + Assert.Equal("1719505260513-0", result.RecordedFirstEntryId.ToString()); + Assert.Equal(100, result.IdmpDuration); + Assert.Equal(100, result.IdmpMaxSize); + Assert.Equal(1, result.PidsTracked); + Assert.Equal(1, result.IidsTracked); + Assert.Equal(1, result.IidsAdded); + Assert.Equal(0, result.IidsDuplicates); + + Assert.Equal("1638125133432-0", result.FirstEntry.Id.ToString()); + Assert.Equal("apple", result.FirstEntry["message"]); + + Assert.Equal("1638125141232-0", result.LastEntry.Id.ToString()); + Assert.Equal("banana", result.LastEntry["message"]); + } + + [Fact] + public void MinimalFormat_Success() + { + // Minimal XINFO STREAM response with just required fields + var resp = "*14\r\n" + + "$6\r\nlength\r\n" + + ":0\r\n" + + "$15\r\nradix-tree-keys\r\n" + + ":1\r\n" + + "$16\r\nradix-tree-nodes\r\n" + + ":1\r\n" + + "$6\r\ngroups\r\n" + + ":0\r\n" + + "$11\r\nfirst-entry\r\n" + + "$-1\r\n" + + "$10\r\nlast-entry\r\n" + + "$-1\r\n" + + "$17\r\nlast-generated-id\r\n" + + "$3\r\n0-0\r\n"; + + var result = Execute(resp, ResultProcessor.StreamInfo); + + Assert.Equal(0, result.Length); + Assert.Equal(1, result.RadixTreeKeys); + Assert.Equal(1, result.RadixTreeNodes); + Assert.Equal(0, result.ConsumerGroupCount); + Assert.True(result.FirstEntry.IsNull); + Assert.True(result.LastEntry.IsNull); + } + + [Fact] + public void NotArray_Failure() + { + var resp = "$5\r\nhello\r\n"; + + ExecuteUnexpected(resp, ResultProcessor.StreamInfo); + } + + [Fact] + public void Null_Failure() + { + var resp = "$-1\r\n"; + + ExecuteUnexpected(resp, ResultProcessor.StreamInfo); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/StreamPendingInfo.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/StreamPendingInfo.cs new file mode 100644 index 000000000..6655c6c08 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/StreamPendingInfo.cs @@ -0,0 +1,113 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class StreamPendingInfo(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Fact] + public void SingleConsumer_Success() + { + // XPENDING mystream group55 + // 1) (integer) 1 + // 2) 1526984818136-0 + // 3) 1526984818136-0 + // 4) 1) 1) "consumer-123" + // 2) "1" + var resp = "*4\r\n" + + ":1\r\n" + + "$15\r\n1526984818136-0\r\n" + + "$15\r\n1526984818136-0\r\n" + + "*1\r\n" + // Array of 1 consumer + "*2\r\n" + // Each consumer is an array of 2 elements + "$12\r\nconsumer-123\r\n" + // Consumer name + "$1\r\n1\r\n"; // Pending count as string + + var result = Execute(resp, ResultProcessor.StreamPendingInfo); + + Assert.Equal(1, result.PendingMessageCount); + Assert.Equal("1526984818136-0", result.LowestPendingMessageId); + Assert.Equal("1526984818136-0", result.HighestPendingMessageId); + Assert.Single(result.Consumers); + Assert.Equal("consumer-123", result.Consumers[0].Name); + Assert.Equal(1, result.Consumers[0].PendingMessageCount); + } + + [Fact] + public void MultipleConsumers_Success() + { + // XPENDING mystream mygroup + // 1) (integer) 10 + // 2) 1526569498055-0 + // 3) 1526569506935-0 + // 4) 1) 1) "Bob" + // 2) "2" + // 2) 1) "Joe" + // 2) "8" + var resp = "*4\r\n" + + ":10\r\n" + + "$15\r\n1526569498055-0\r\n" + + "$15\r\n1526569506935-0\r\n" + + "*2\r\n" + // Array of 2 consumers + "*2\r\n" + // First consumer array + "$3\r\nBob\r\n" + + "$1\r\n2\r\n" + + "*2\r\n" + // Second consumer array + "$3\r\nJoe\r\n" + + "$1\r\n8\r\n"; + + var result = Execute(resp, ResultProcessor.StreamPendingInfo); + + Assert.Equal(10, result.PendingMessageCount); + Assert.Equal("1526569498055-0", result.LowestPendingMessageId); + Assert.Equal("1526569506935-0", result.HighestPendingMessageId); + Assert.Equal(2, result.Consumers.Length); + Assert.Equal("Bob", result.Consumers[0].Name); + Assert.Equal(2, result.Consumers[0].PendingMessageCount); + Assert.Equal("Joe", result.Consumers[1].Name); + Assert.Equal(8, result.Consumers[1].PendingMessageCount); + } + + [Fact] + public void NoConsumers_Success() + { + // When there are no consumers yet, the 4th element is null + var resp = "*4\r\n" + + ":0\r\n" + + "$15\r\n1526569498055-0\r\n" + + "$15\r\n1526569506935-0\r\n" + + "$-1\r\n"; // null + + var result = Execute(resp, ResultProcessor.StreamPendingInfo); + + Assert.Equal(0, result.PendingMessageCount); + Assert.Empty(result.Consumers); + } + + [Fact] + public void WrongArrayLength_Failure() + { + // Array with wrong length (3 instead of 4) + var resp = "*3\r\n" + + ":1\r\n" + + "$15\r\n1526984818136-0\r\n" + + "$15\r\n1526984818136-0\r\n"; + + ExecuteUnexpected(resp, ResultProcessor.StreamPendingInfo); + } + + [Fact] + public void NotArray_Failure() + { + var resp = "$5\r\nhello\r\n"; + + ExecuteUnexpected(resp, ResultProcessor.StreamPendingInfo); + } + + [Fact] + public void Null_Failure() + { + var resp = "$-1\r\n"; + + ExecuteUnexpected(resp, ResultProcessor.StreamPendingInfo); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/StreamPendingMessages.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/StreamPendingMessages.cs new file mode 100644 index 000000000..48357a3f8 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/StreamPendingMessages.cs @@ -0,0 +1,99 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class StreamPendingMessages(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Fact] + public void SingleMessage_Success() + { + // XPENDING mystream group55 - + 10 + // 1) 1) 1526984818136-0 + // 2) "consumer-123" + // 3) (integer) 196415 + // 4) (integer) 1 + var resp = "*1\r\n" + // Array of 1 message + "*4\r\n" + // Each message is an array of 4 elements + "$15\r\n1526984818136-0\r\n" + // Message ID + "$12\r\nconsumer-123\r\n" + // Consumer name + ":196415\r\n" + // Idle time in ms + ":1\r\n"; // Delivery count + + var result = Execute(resp, ResultProcessor.StreamPendingMessages); + + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("1526984818136-0", result[0].MessageId); + Assert.Equal("consumer-123", result[0].ConsumerName); + Assert.Equal(196415, result[0].IdleTimeInMilliseconds); + Assert.Equal(1, result[0].DeliveryCount); + } + + [Fact] + public void MultipleMessages_Success() + { + // XPENDING mystream group55 - + 10 + // 1) 1) 1526984818136-0 + // 2) "consumer-123" + // 3) (integer) 196415 + // 4) (integer) 1 + // 2) 1) 1526984818137-0 + // 2) "consumer-456" + // 3) (integer) 5000 + // 4) (integer) 3 + var resp = "*2\r\n" + // Array of 2 messages + "*4\r\n" + // First message + "$15\r\n1526984818136-0\r\n" + + "$12\r\nconsumer-123\r\n" + + ":196415\r\n" + + ":1\r\n" + + "*4\r\n" + // Second message + "$15\r\n1526984818137-0\r\n" + + "$12\r\nconsumer-456\r\n" + + ":5000\r\n" + + ":3\r\n"; + + var result = Execute(resp, ResultProcessor.StreamPendingMessages); + + Assert.NotNull(result); + Assert.Equal(2, result.Length); + + Assert.Equal("1526984818136-0", result[0].MessageId); + Assert.Equal("consumer-123", result[0].ConsumerName); + Assert.Equal(196415, result[0].IdleTimeInMilliseconds); + Assert.Equal(1, result[0].DeliveryCount); + + Assert.Equal("1526984818137-0", result[1].MessageId); + Assert.Equal("consumer-456", result[1].ConsumerName); + Assert.Equal(5000, result[1].IdleTimeInMilliseconds); + Assert.Equal(3, result[1].DeliveryCount); + } + + [Fact] + public void EmptyArray_Success() + { + // No pending messages + var resp = "*0\r\n"; + + var result = Execute(resp, ResultProcessor.StreamPendingMessages); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void NotArray_Failure() + { + var resp = "$5\r\nhello\r\n"; + + ExecuteUnexpected(resp, ResultProcessor.StreamPendingMessages); + } + + [Fact] + public void Null_Failure() + { + var resp = "$-1\r\n"; + + ExecuteUnexpected(resp, ResultProcessor.StreamPendingMessages); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Streams.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Streams.cs new file mode 100644 index 000000000..b011f0e9e --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Streams.cs @@ -0,0 +1,195 @@ +using System; +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class Streams(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + // MultiStreamProcessor tests - XREAD command + // Format: array of [stream_name, array of entries] + // Each entry is [id, array of name/value pairs] + [Fact] + public void MultiStreamProcessor_EmptyResult() + { + // Server returns nil when no data available + var resp = "*-1\r\n"; + var processor = ResultProcessor.MultiStream; + var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void MultiStreamProcessor_EmptyArray() + { + // Server returns empty array + var resp = "*0\r\n"; + var processor = ResultProcessor.MultiStream; + var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void MultiStreamProcessor_SingleStreamSingleEntry() + { + // XREAD COUNT 1 STREAMS mystream 0-0 + // 1) 1) "mystream" + // 2) 1) 1) "1526984818136-0" + // 2) 1) "duration" + // 2) "1532" + // 3) "event-id" + // 4) "5" + var resp = "*1\r\n" + // 1 stream + "*2\r\n" + // [stream_name, entries] + "$8\r\nmystream\r\n" + // stream name + "*1\r\n" + // 1 entry + "*2\r\n" + // [id, values] + "$15\r\n1526984818136-0\r\n" + // entry id + "*4\r\n" + // 2 name/value pairs (interleaved) + "$8\r\nduration\r\n" + + "$4\r\n1532\r\n" + + "$8\r\nevent-id\r\n" + + "$1\r\n5\r\n"; + + var processor = ResultProcessor.MultiStream; + var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("mystream", (string?)result[0].Key); + Assert.Single(result[0].Entries); + Assert.Equal("1526984818136-0", (string?)result[0].Entries[0].Id); + Assert.Equal(2, result[0].Entries[0].Values.Length); + Assert.Equal("duration", (string?)result[0].Entries[0].Values[0].Name); + Assert.Equal("1532", (string?)result[0].Entries[0].Values[0].Value); + Assert.Equal("event-id", (string?)result[0].Entries[0].Values[1].Name); + Assert.Equal("5", (string?)result[0].Entries[0].Values[1].Value); + } + + [Fact] + public void MultiStreamProcessor_MultipleStreamsMultipleEntries() + { + // XREAD COUNT 2 STREAMS mystream writers 0-0 0-0 + // (see ResultProcessor.cs lines 2336-2358 for the redis-cli format) + var resp = "*2\r\n" + // 2 streams + // First stream: mystream + "*2\r\n" + + "$8\r\nmystream\r\n" + + "*2\r\n" + // 2 entries + "*2\r\n" + + "$15\r\n1526984818136-0\r\n" + + "*4\r\n" + + "$8\r\nduration\r\n$4\r\n1532\r\n$8\r\nevent-id\r\n$1\r\n5\r\n" + + "*2\r\n" + + "$15\r\n1526999352406-0\r\n" + + "*4\r\n" + + "$8\r\nduration\r\n$3\r\n812\r\n$8\r\nevent-id\r\n$1\r\n9\r\n" + + // Second stream: writers + "*2\r\n" + + "$7\r\nwriters\r\n" + + "*2\r\n" + // 2 entries + "*2\r\n" + + "$15\r\n1526985676425-0\r\n" + + "*4\r\n" + + "$4\r\nname\r\n$8\r\nVirginia\r\n$7\r\nsurname\r\n$5\r\nWoolf\r\n" + + "*2\r\n" + + "$15\r\n1526985685298-0\r\n" + + "*4\r\n" + + "$4\r\nname\r\n$4\r\nJane\r\n$7\r\nsurname\r\n$6\r\nAusten\r\n"; + + var processor = ResultProcessor.MultiStream; + var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(2, result.Length); + + // First stream: mystream + Assert.Equal("mystream", (string?)result[0].Key); + Assert.Equal(2, result[0].Entries.Length); + Assert.Equal("1526984818136-0", (string?)result[0].Entries[0].Id); + Assert.Equal("duration", (string?)result[0].Entries[0].Values[0].Name); + Assert.Equal("1532", (string?)result[0].Entries[0].Values[0].Value); + Assert.Equal("event-id", (string?)result[0].Entries[0].Values[1].Name); + Assert.Equal("5", (string?)result[0].Entries[0].Values[1].Value); + Assert.Equal("1526999352406-0", (string?)result[0].Entries[1].Id); + Assert.Equal("duration", (string?)result[0].Entries[1].Values[0].Name); + Assert.Equal("812", (string?)result[0].Entries[1].Values[0].Value); + + // Second stream: writers + Assert.Equal("writers", (string?)result[1].Key); + Assert.Equal(2, result[1].Entries.Length); + Assert.Equal("1526985676425-0", (string?)result[1].Entries[0].Id); + Assert.Equal("name", (string?)result[1].Entries[0].Values[0].Name); + Assert.Equal("Virginia", (string?)result[1].Entries[0].Values[0].Value); + Assert.Equal("surname", (string?)result[1].Entries[0].Values[1].Name); + Assert.Equal("Woolf", (string?)result[1].Entries[0].Values[1].Value); + Assert.Equal("1526985685298-0", (string?)result[1].Entries[1].Id); + Assert.Equal("name", (string?)result[1].Entries[1].Values[0].Name); + Assert.Equal("Jane", (string?)result[1].Entries[1].Values[0].Value); + Assert.Equal("surname", (string?)result[1].Entries[1].Values[1].Name); + Assert.Equal("Austen", (string?)result[1].Entries[1].Values[1].Value); + } + + // XREADGROUP tests - same format as XREAD (uses MultiStream processor) + // RESP2: Array reply with [stream_name, array of entries] + // RESP3: Map reply with key-value pairs + [Theory] + [InlineData(RedisProtocol.Resp2, "*1\r\n*2\r\n$8\r\nmystream\r\n*1\r\n*2\r\n$3\r\n1-0\r\n*2\r\n$7\r\nmyfield\r\n$6\r\nmydata\r\n")] + [InlineData(RedisProtocol.Resp3, "%1\r\n$8\r\nmystream\r\n*1\r\n*2\r\n$3\r\n1-0\r\n*2\r\n$7\r\nmyfield\r\n$6\r\nmydata\r\n")] + public void MultiStreamProcessor_XReadGroup_SingleStreamSingleEntry(RedisProtocol protocol, string resp) + { + // XREADGROUP GROUP mygroup myconsumer STREAMS mystream > + // 1) 1) "mystream" + // 2) 1) 1) "1-0" + // 2) 1) "myfield" + // 2) "mydata" + var processor = ResultProcessor.MultiStream; + var result = Execute(resp, processor, protocol: protocol); + + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("mystream", (string?)result[0].Key); + Assert.Single(result[0].Entries); + Assert.Equal("1-0", (string?)result[0].Entries[0].Id); + Assert.Single(result[0].Entries[0].Values); + Assert.Equal("myfield", (string?)result[0].Entries[0].Values[0].Name); + Assert.Equal("mydata", (string?)result[0].Entries[0].Values[0].Value); + } + + [Theory] + [InlineData(RedisProtocol.Resp2, "*1\r\n*2\r\n$8\r\nmystream\r\n*1\r\n*2\r\n$3\r\n1-0\r\n*-1\r\n")] + [InlineData(RedisProtocol.Resp3, "%1\r\n$8\r\nmystream\r\n*1\r\n*2\r\n$3\r\n1-0\r\n_\r\n")] + public void MultiStreamProcessor_XReadGroup_PendingMessageWithNilValues(RedisProtocol protocol, string resp) + { + // XREADGROUP GROUP mygroup myconsumer STREAMS mystream 0 + // Reading pending messages returns nil for values if already acknowledged + // 1) 1) "mystream" + // 2) 1) 1) "1-0" + // 2) (nil) + var processor = ResultProcessor.MultiStream; + var result = Execute(resp, processor, protocol: protocol); + + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("mystream", (string?)result[0].Key); + Assert.Single(result[0].Entries); + Assert.Equal("1-0", (string?)result[0].Entries[0].Id); + Assert.Empty(result[0].Entries[0].Values); // nil becomes empty array + } + + [Theory] + [InlineData(RedisProtocol.Resp2, "*-1\r\n")] + [InlineData(RedisProtocol.Resp3, "_\r\n")] + public void MultiStreamProcessor_XReadGroup_Timeout(RedisProtocol protocol, string resp) + { + // XREADGROUP with BLOCK that times out returns nil/null + var processor = ResultProcessor.MultiStream; + var result = Execute(resp, processor, protocol: protocol); + + Assert.NotNull(result); + Assert.Empty(result); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/TimeSpan.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/TimeSpan.cs new file mode 100644 index 000000000..daf173300 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/TimeSpan.cs @@ -0,0 +1,100 @@ +using System; +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +/// +/// Tests for TimeSpanProcessor +/// +public class TimeSpanTests(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Theory] + [InlineData(":0\r\n", 0)] + [InlineData(":1\r\n", 1)] + [InlineData(":1000\r\n", 1000)] + [InlineData(":60\r\n", 60)] + [InlineData(":3600\r\n", 3600)] + public void TimeSpanFromSeconds_ValidInteger(string resp, long seconds) + { + var processor = ResultProcessor.TimeSpanFromSeconds; + var result = Execute(resp, processor); + Assert.NotNull(result); + Assert.Equal(TimeSpan.FromSeconds(seconds), result.Value); + } + + [Theory] + [InlineData(":0\r\n", 0)] + [InlineData(":1\r\n", 1)] + [InlineData(":1000\r\n", 1000)] + [InlineData(":60000\r\n", 60000)] + [InlineData(":3600000\r\n", 3600000)] + public void TimeSpanFromMilliseconds_ValidInteger(string resp, long milliseconds) + { + var processor = ResultProcessor.TimeSpanFromMilliseconds; + var result = Execute(resp, processor); + Assert.NotNull(result); + Assert.Equal(TimeSpan.FromMilliseconds(milliseconds), result.Value); + } + + [Theory] + [InlineData(":-1\r\n")] + [InlineData(":-2\r\n")] + [InlineData(":-100\r\n")] + public void TimeSpanFromSeconds_NegativeInteger_ReturnsNull(string resp) + { + var processor = ResultProcessor.TimeSpanFromSeconds; + var result = Execute(resp, processor); + Assert.Null(result); + } + + [Theory] + [InlineData(":-1\r\n")] + [InlineData(":-2\r\n")] + [InlineData(":-100\r\n")] + public void TimeSpanFromMilliseconds_NegativeInteger_ReturnsNull(string resp) + { + var processor = ResultProcessor.TimeSpanFromMilliseconds; + var result = Execute(resp, processor); + Assert.Null(result); + } + + [Theory] + [InlineData("$-1\r\n")] // RESP2 null bulk string + [InlineData("_\r\n")] // RESP3 null + public void TimeSpanFromSeconds_Null_ReturnsNull(string resp) + { + var processor = ResultProcessor.TimeSpanFromSeconds; + var result = Execute(resp, processor); + Assert.Null(result); + } + + [Theory] + [InlineData("$-1\r\n")] // RESP2 null bulk string + [InlineData("_\r\n")] // RESP3 null + public void TimeSpanFromMilliseconds_Null_ReturnsNull(string resp) + { + var processor = ResultProcessor.TimeSpanFromMilliseconds; + var result = Execute(resp, processor); + Assert.Null(result); + } + + [Theory] + [InlineData("+OK\r\n")] + [InlineData("$2\r\nOK\r\n")] + [InlineData("*2\r\n:1\r\n:2\r\n")] + public void TimeSpanFromSeconds_InvalidType(string resp) + { + var processor = ResultProcessor.TimeSpanFromSeconds; + ExecuteUnexpected(resp, processor); + } + + [Theory] + [InlineData("+OK\r\n")] + [InlineData("$2\r\nOK\r\n")] + [InlineData("*2\r\n:1\r\n:2\r\n")] + public void TimeSpanFromMilliseconds_InvalidType(string resp) + { + var processor = ResultProcessor.TimeSpanFromMilliseconds; + ExecuteUnexpected(resp, processor); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Timing.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Timing.cs new file mode 100644 index 000000000..644ca9ef2 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/Timing.cs @@ -0,0 +1,27 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class Timing(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Theory] + [InlineData("+OK\r\n")] + [InlineData(":42\r\n")] + [InlineData(":0\r\n")] + [InlineData(":-1\r\n")] + [InlineData("$5\r\nhello\r\n")] + [InlineData("$0\r\n\r\n")] + [InlineData("$-1\r\n")] + [InlineData("*2\r\n:1\r\n:2\r\n")] + [InlineData("*0\r\n")] + [InlineData("_\r\n")] + public void Timing_ValidResponse_ReturnsTimeSpan(string resp) + { + var processor = ResultProcessor.ResponseTimer; + var message = ResultProcessor.TimingProcessor.CreateMessage(-1, CommandFlags.None, RedisCommand.PING); + var result = Execute(resp, processor, message); + + Assert.NotEqual(System.TimeSpan.MaxValue, result); + Assert.True(result >= System.TimeSpan.Zero); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/TracerProcessor.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/TracerProcessor.cs new file mode 100644 index 000000000..606f51242 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/TracerProcessor.cs @@ -0,0 +1,30 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class TracerProcessor(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Fact] + public void Ping_Pong_Success() + { + // PING response - simple string + var resp = "+PONG\r\n"; + var message = Message.Create(-1, default, RedisCommand.PING); + + var result = Execute(resp, ResultProcessor.Tracer, message); + + Assert.True(result); + } + + [Fact] + public void Time_Success() + { + // TIME response - array of 2 elements + var resp = "*2\r\n$10\r\n1609459200\r\n$6\r\n123456\r\n"; + var message = Message.Create(-1, default, RedisCommand.TIME); + + var result = Execute(resp, ResultProcessor.Tracer, message); + + Assert.True(result); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/TrackSubscriptions.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/TrackSubscriptions.cs new file mode 100644 index 000000000..ceb82cb25 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/TrackSubscriptions.cs @@ -0,0 +1,19 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class TrackSubscriptions(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Theory] + [InlineData("*3\r\n$9\r\nsubscribe\r\n$7\r\nchannel\r\n:1\r\n", 1)] // SUBSCRIBE response with count 1 + [InlineData("*3\r\n$9\r\nsubscribe\r\n$7\r\nchannel\r\n:5\r\n", 5)] // SUBSCRIBE response with count 5 + [InlineData("*3\r\n$11\r\nunsubscribe\r\n$7\r\nchannel\r\n:0\r\n", 0)] // UNSUBSCRIBE response with count 0 + [InlineData("*3\r\n$10\r\npsubscribe\r\n$8\r\npattern*\r\n:2\r\n", 2)] // PSUBSCRIBE response with count 2 + public void TrackSubscriptions_Success(string resp, int expectedCount) + { + var processor = ResultProcessor.TrackSubscriptions; + var result = Execute(resp, processor); + Assert.True(result); + Log($"Successfully parsed subscription response with count {expectedCount}"); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/VectorSet.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/VectorSet.cs new file mode 100644 index 000000000..8b54e2e2e --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/VectorSet.cs @@ -0,0 +1,190 @@ +using Xunit; + +namespace StackExchange.Redis.Tests.ResultProcessorUnitTests; + +public class VectorSet(ITestOutputHelper log) : ResultProcessorUnitTest(log) +{ + [Theory] + [InlineData("*0\r\n")] // empty array + [InlineData("*12\r\n$4\r\nsize\r\n:100\r\n$8\r\nvset-uid\r\n:42\r\n$9\r\nmax-level\r\n:5\r\n$10\r\nvector-dim\r\n:128\r\n$10\r\nquant-type\r\n$4\r\nint8\r\n$17\r\nhnsw-max-node-uid\r\n:99\r\n")] // full info with int8 + public void VectorSetInfo_ValidInput(string resp) + { + var processor = ResultProcessor.VectorSetInfo; + var result = Execute(resp, processor); + + Assert.NotNull(result); + } + + [Fact] + public void VectorSetInfo_EmptyArray_ReturnsDefaults() + { + // Empty array should return VectorSetInfo with default values + var resp = "*0\r\n"; + var processor = ResultProcessor.VectorSetInfo; + var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(VectorSetQuantization.Unknown, result.Value.Quantization); + Assert.Null(result.Value.QuantizationRaw); + Assert.Equal(0, result.Value.Dimension); + Assert.Equal(0, result.Value.Length); + Assert.Equal(0, result.Value.MaxLevel); + Assert.Equal(0, result.Value.VectorSetUid); + Assert.Equal(0, result.Value.HnswMaxNodeUid); + } + + [Theory] + [InlineData("*-1\r\n")] // null array (RESP2) + [InlineData("_\r\n")] // null (RESP3) + public void VectorSetInfo_NullArray(string resp) + { + var processor = ResultProcessor.VectorSetInfo; + var result = Execute(resp, processor); + + Assert.Null(result); + } + + [Fact] + public void VectorSetInfo_ValidatesContent_Int8() + { + // VINFO response with int8 quantization + var resp = "*12\r\n$4\r\nsize\r\n:100\r\n$8\r\nvset-uid\r\n:42\r\n$9\r\nmax-level\r\n:5\r\n$10\r\nvector-dim\r\n:128\r\n$10\r\nquant-type\r\n$4\r\nint8\r\n$17\r\nhnsw-max-node-uid\r\n:99\r\n"; + var processor = ResultProcessor.VectorSetInfo; + var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(VectorSetQuantization.Int8, result.Value.Quantization); + Assert.Null(result.Value.QuantizationRaw); + Assert.Equal(128, result.Value.Dimension); + Assert.Equal(100, result.Value.Length); + Assert.Equal(5, result.Value.MaxLevel); + Assert.Equal(42, result.Value.VectorSetUid); + Assert.Equal(99, result.Value.HnswMaxNodeUid); + } + + [Fact] + public void VectorSetInfo_SkipsNonScalarValues() + { + // Response with a non-scalar value (array) that should be skipped + // Format: size:100, unknown-field:[1,2,3], vset-uid:42 + var resp = "*6\r\n$4\r\nsize\r\n:100\r\n$13\r\nunknown-field\r\n*3\r\n:1\r\n:2\r\n:3\r\n$8\r\nvset-uid\r\n:42\r\n"; + var processor = ResultProcessor.VectorSetInfo; + var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(100, result.Value.Length); + Assert.Equal(42, result.Value.VectorSetUid); + // Other fields should have default values + Assert.Equal(VectorSetQuantization.Unknown, result.Value.Quantization); + Assert.Equal(0, result.Value.Dimension); + } + + [Fact] + public void VectorSetLinks_EmptyArray() + { + // VLINKS returns empty array + var resp = "*0\r\n"; + var processor = ResultProcessor.VectorSetLinks; + using var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(0, result.Length); + } + + [Theory] + [InlineData("*-1\r\n")] // null array (RESP2) + [InlineData("_\r\n")] // null (RESP3) + public void VectorSetLinks_NullArray(string resp) + { + var processor = ResultProcessor.VectorSetLinks; + using var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(0, result.Length); + } + + [Fact] + public void VectorSetLinks_SingleNestedArray() + { + // VLINKS returns [[element1]] + var resp = "*1\r\n*1\r\n$8\r\nelement1\r\n"; + var processor = ResultProcessor.VectorSetLinks; + using var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(1, result.Length); + Assert.Equal("element1", result.Span[0].ToString()); + } + + [Fact] + public void VectorSetLinks_MultipleNestedArrays() + { + // VLINKS returns [[element1], [element2, element3], [element4]] + var resp = "*3\r\n*1\r\n$8\r\nelement1\r\n*2\r\n$8\r\nelement2\r\n$8\r\nelement3\r\n*1\r\n$8\r\nelement4\r\n"; + var processor = ResultProcessor.VectorSetLinks; + using var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(4, result.Length); + Assert.Equal("element1", result.Span[0].ToString()); + Assert.Equal("element2", result.Span[1].ToString()); + Assert.Equal("element3", result.Span[2].ToString()); + Assert.Equal("element4", result.Span[3].ToString()); + } + + [Fact] + public void VectorSetLinksWithScores_EmptyArray() + { + // VLINKS WITHSCORES returns empty array + var resp = "*0\r\n"; + var processor = ResultProcessor.VectorSetLinksWithScores; + using var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(0, result.Length); + } + + [Theory] + [InlineData("*-1\r\n")] // null array (RESP2) + [InlineData("_\r\n")] // null (RESP3) + public void VectorSetLinksWithScores_NullArray(string resp) + { + var processor = ResultProcessor.VectorSetLinksWithScores; + using var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(0, result.Length); + } + + [Fact] + public void VectorSetLinksWithScores_SingleNestedArray() + { + // VLINKS WITHSCORES returns [[element1, score1]] + var resp = "*1\r\n*2\r\n$8\r\nelement1\r\n$3\r\n1.5\r\n"; + var processor = ResultProcessor.VectorSetLinksWithScores; + using var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(1, result.Length); + Assert.Equal("element1", result.Span[0].Member.ToString()); + Assert.Equal(1.5, result.Span[0].Score); + } + + [Fact] + public void VectorSetLinksWithScores_MultipleNestedArrays() + { + // VLINKS WITHSCORES returns [[element1, score1], [element2, score2], [element3, score3]] + var resp = "*3\r\n*2\r\n$8\r\nelement1\r\n$3\r\n1.5\r\n*2\r\n$8\r\nelement2\r\n$3\r\n2.5\r\n*2\r\n$8\r\nelement3\r\n$3\r\n3.5\r\n"; + var processor = ResultProcessor.VectorSetLinksWithScores; + using var result = Execute(resp, processor); + + Assert.NotNull(result); + Assert.Equal(3, result.Length); + Assert.Equal("element1", result.Span[0].Member.ToString()); + Assert.Equal(1.5, result.Span[0].Score); + Assert.Equal("element2", result.Span[1].Member.ToString()); + Assert.Equal(2.5, result.Span[1].Score); + Assert.Equal("element3", result.Span[2].Member.ToString()); + Assert.Equal(3.5, result.Span[2].Score); + } +} diff --git a/tests/StackExchange.Redis.Tests/RoundTripUnitTests/AdhocMessageRoundTrip.cs b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/AdhocMessageRoundTrip.cs new file mode 100644 index 000000000..34d41e884 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/AdhocMessageRoundTrip.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests.RoundTripUnitTests; + +public class AdHocMessageRoundTrip(ITestOutputHelper log) +{ + public enum MapMode + { + Null, + Default, + Disabled, + Renamed, + } + + [Theory(Timeout = 1000)] + [InlineData(MapMode.Null, "", "*1\r\n$4\r\nECHO\r\n")] + [InlineData(MapMode.Default, "", "*1\r\n$4\r\nECHO\r\n")] + [InlineData(MapMode.Disabled, "", "")] + [InlineData(MapMode.Renamed, "", "*1\r\n$5\r\nECHO2\r\n")] + [InlineData(MapMode.Null, "hello", "*2\r\n$4\r\nECHO\r\n$5\r\nhello\r\n")] + [InlineData(MapMode.Default, "hello", "*2\r\n$4\r\nECHO\r\n$5\r\nhello\r\n")] + [InlineData(MapMode.Disabled, "hello", "")] + [InlineData(MapMode.Renamed, "hello", "*2\r\n$5\r\nECHO2\r\n$5\r\nhello\r\n")] + public async Task EchoRoundTripTest(MapMode mode, string payload, string requestResp) + { + var map = GetMap(mode); + + object[] args = string.IsNullOrEmpty(payload) ? [] : [payload]; + if (mode is MapMode.Disabled) + { + var ex = Assert.Throws(() => new RedisDatabase.ExecuteMessage(map, -1, CommandFlags.None, "echo", args)); + Assert.StartsWith(ex.Message, "This operation has been disabled in the command-map and cannot be used: echo"); + } + else + { + var msg = new RedisDatabase.ExecuteMessage(map, -1, CommandFlags.None, "echo", args); + Assert.Equal(RedisCommand.ECHO, msg.Command); // in v3: this is recognized correctly + + Assert.Equal("ECHO", msg.CommandAndKey); + Assert.Equal("ECHO", msg.CommandString); + var result = + await TestConnection.ExecuteAsync(msg, ResultProcessor.ScriptResult, requestResp, ":5\r\n", commandMap: map, log: log); + Assert.Equal(ResultType.Integer, result.Resp3Type); + Assert.Equal(5, result.AsInt32()); + } + } + + private static CommandMap? GetMap(MapMode mode) => mode switch + { + MapMode.Null => null, + MapMode.Default => CommandMap.Default, + MapMode.Disabled => CommandMap.Create(new HashSet { "echo", "custom" }, available: false), + MapMode.Renamed => CommandMap.Create(new Dictionary { { "echo", "echo2" }, { "custom", "custom2" } }), + _ => throw new ArgumentOutOfRangeException(nameof(mode)), + }; + + [Theory(Timeout = 1000)] + [InlineData(MapMode.Null, "", "*1\r\n$6\r\nCUSTOM\r\n")] + [InlineData(MapMode.Default, "", "*1\r\n$6\r\nCUSTOM\r\n")] + // [InlineData(MapMode.Disabled, "", "")] + // [InlineData(MapMode.Renamed, "", "*1\r\n$7\r\nCUSTOM2\r\n")] + [InlineData(MapMode.Null, "hello", "*2\r\n$6\r\nCUSTOM\r\n$5\r\nhello\r\n")] + [InlineData(MapMode.Default, "hello", "*2\r\n$6\r\nCUSTOM\r\n$5\r\nhello\r\n")] + // [InlineData(MapMode.Disabled, "hello", "")] + // [InlineData(MapMode.Renamed, "hello", "*2\r\n$7\r\nCUSTOM2\r\n$5\r\nhello\r\n")] + public async Task CustomRoundTripTest(MapMode mode, string payload, string requestResp) + { + var map = GetMap(mode); + + object[] args = string.IsNullOrEmpty(payload) ? [] : [payload]; + if (mode is MapMode.Disabled) + { + var ex = Assert.Throws(() => new RedisDatabase.ExecuteMessage(map, -1, CommandFlags.None, "custom", args)); + Assert.StartsWith(ex.Message, "This operation has been disabled in the command-map and cannot be used: custom"); + } + else + { + var msg = new RedisDatabase.ExecuteMessage(map, -1, CommandFlags.None, "custom", args); + Assert.Equal(RedisCommand.UNKNOWN, msg.Command); + + Assert.Equal("custom", msg.CommandAndKey); + Assert.Equal("custom", msg.CommandString); + var result = + await TestConnection.ExecuteAsync(msg, ResultProcessor.ScriptResult, requestResp, ":5\r\n", commandMap: map, log: log); + Assert.Equal(ResultType.Integer, result.Resp3Type); + Assert.Equal(5, result.AsInt32()); + } + } +} diff --git a/tests/StackExchange.Redis.Tests/RoundTripUnitTests/EchoRoundTrip.cs b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/EchoRoundTrip.cs new file mode 100644 index 000000000..851ba7c4f --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/EchoRoundTrip.cs @@ -0,0 +1,17 @@ +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests.RoundTripUnitTests; + +public class EchoRoundTrip +{ + [Theory(Timeout = 1000)] + [InlineData("hello", "*2\r\n$4\r\nECHO\r\n$5\r\nhello\r\n", "+hello\r\n")] + [InlineData("hello", "*2\r\n$4\r\nECHO\r\n$5\r\nhello\r\n", "$5\r\nhello\r\n")] + public async Task EchoRoundTripTest(string payload, string requestResp, string responseResp) + { + var msg = Message.Create(-1, CommandFlags.None, RedisCommand.ECHO, (RedisValue)payload); + var result = await TestConnection.ExecuteAsync(msg, ResultProcessor.String, requestResp, responseResp); + Assert.Equal(payload, result); + } +} diff --git a/tests/StackExchange.Redis.Tests/RoundTripUnitTests/TestConnection.cs b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/TestConnection.cs new file mode 100644 index 000000000..ea7c90d29 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RoundTripUnitTests/TestConnection.cs @@ -0,0 +1,142 @@ +using System; +using System.Buffers; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using RESPite.Tests; +using Xunit; + +namespace StackExchange.Redis.Tests.RoundTripUnitTests; + +public static class CancellationTokenExtensions +{ + public static CancellationTokenRegistration CancelWithTest(this TaskCompletionSource tcs) + => CancelWith(tcs, TestContext.Current.CancellationToken); + public static CancellationTokenRegistration CancelWith(this TaskCompletionSource tcs, CancellationToken token) + { + if (token.CanBeCanceled) + { + token.ThrowIfCancellationRequested(); + return token.Register(() => tcs.TrySetCanceled(token)); // note capture for token: deal with it + } + + return default; + } +} + +public class TestConnection : IDisposable +{ + internal static async Task ExecuteAsync( + Message message, + ResultProcessor processor, + string requestResp, + string responseResp, + ConnectionType connectionType = ConnectionType.Interactive, + RedisProtocol protocol = RedisProtocol.Resp2, + CommandMap? commandMap = null, + byte[]? channelPrefix = null, + ITestOutputHelper? log = null, + WriteMode writeMode = WriteMode.Default, + [CallerMemberName] string caller = "") + { + // Validate RESP samples are not null/empty to avoid test setup mistakes + Assert.False(string.IsNullOrEmpty(responseResp), "responseResp must not be null or empty"); + + using var conn = new TestConnection(false, writeMode, connectionType, protocol, log, caller); + + var box = TaskResultBox.Create(out var tcs, null); + using var timeout = tcs.CancelWithTest(); + + message.SetSource(box, processor); + conn.WriteOutbound(message, commandMap, channelPrefix); + Assert.Equal(TaskStatus.WaitingForActivation, tcs.Task.Status); // should be pending, since we haven't responded yet + await conn.CompleteOutputAsync(); + + // check the request + conn.AssertOutbound(requestResp); + + // since the request was good, we can start reading + conn.StartReading(); + await conn.AddInboundAsync(responseResp); + return await tcs.Task; + } + + private Task CompleteOutputAsync(Exception? exception = null) => _physical.CompleteOutputAsync(exception); + + public TestConnection( + bool startReading = true, + WriteMode writeMode = WriteMode.Default, + ConnectionType connectionType = ConnectionType.Interactive, + RedisProtocol protocol = RedisProtocol.Resp2, + ITestOutputHelper? log = null, + [CallerMemberName] string caller = "") + { + _physical = new PhysicalConnection(connectionType, protocol, _stream, (BufferedStreamWriter.WriteMode)writeMode, caller); + _log = log; + if (startReading) StartReading(); + } + private readonly TestDuplexStream _stream = new(); + private readonly PhysicalConnection _physical; + private readonly ITestOutputHelper? _log; + + public void StartReading() => _physical.StartReading(TestContext.Current.CancellationToken); + + public ReadOnlySpan GetOutboundData() => _stream.GetOutboundData(); + public void FlushOutboundData() => _stream.FlushOutboundData(); + public ValueTask AddInboundAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) + => _stream.AddInboundAsync(data, cancellationToken); + public ValueTask AddInboundAsync(ReadOnlySpan data, CancellationToken cancellationToken = default) + => _stream.AddInboundAsync(data, cancellationToken); + public ValueTask AddInboundAsync(string data, CancellationToken cancellationToken = default) + => _stream.AddInboundAsync(data, cancellationToken); + + public void Dispose() + { + _physical.Dispose(); + _stream.Dispose(); + } + + internal void WriteOutbound(Message message, CommandMap? commandMap = null, byte[]? channelPrefix = null) + { + _physical.EnqueueInsideWriteLock(message, enforceMuxer: false); + message.WriteTo(_physical, commandMap ?? CommandMap.Default, channelPrefix); + } + + public void AssertOutbound(string expected) + { + // Check max char count and lease a char buffer + var actual = GetOutboundData(); + var maxCharCount = Encoding.UTF8.GetMaxCharCount(actual.Length); + char[]? leased = null; + Span charBuffer = maxCharCount <= 256 + ? stackalloc char[maxCharCount] + : (leased = ArrayPool.Shared.Rent(maxCharCount)); + + try + { + // Decode into the buffer + var actualCharCount = Encoding.UTF8.GetChars(actual, charBuffer); + var actualChars = charBuffer.Slice(0, actualCharCount); + + // Use SequenceEqual to compare + if (actualChars.SequenceEqual(expected.AsSpan())) + { + return; // Success - no allocation needed + } + + // Only if comparison fails: allocate string for useful error message + var actualString = actualChars.ToString(); + _log?.WriteLine("Expected: {0}", expected); + _log?.WriteLine("Actual: {0}", actualString); + Assert.Equal(expected, actualString); + } + finally + { + if (leased != null) + { + ArrayPool.Shared.Return(leased); + } + } + } +} diff --git a/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj b/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj index 4c312d448..48963299d 100644 --- a/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj +++ b/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj @@ -18,9 +18,12 @@ + + + + - @@ -33,5 +36,7 @@ + + diff --git a/tests/StackExchange.Redis.Tests/TestBase.cs b/tests/StackExchange.Redis.Tests/TestBase.cs index 62b841f08..7859263d4 100644 --- a/tests/StackExchange.Redis.Tests/TestBase.cs +++ b/tests/StackExchange.Redis.Tests/TestBase.cs @@ -635,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/tests/StackExchange.Redis.Tests/TestHarnessTests.cs b/tests/StackExchange.Redis.Tests/TestHarnessTests.cs new file mode 100644 index 000000000..c9d96ae6a --- /dev/null +++ b/tests/StackExchange.Redis.Tests/TestHarnessTests.cs @@ -0,0 +1,81 @@ +using System; +using Xunit; +using Xunit.Sdk; + +namespace StackExchange.Redis.Tests; + +// who watches the watchers? +public class TestHarnessTests +{ + // this bit isn't required, but: by subclassing TestHarness we can expose the idiomatic test-framework faults. + private sealed class XUnitTestHarness(CommandMap? commandMap = null, RedisChannel channelPrefix = default, RedisKey keyPrefix = default) + : TestHarness(commandMap, channelPrefix, keyPrefix) + { + protected override void OnValidateFail(string expected, string actual) + => Assert.Equal(expected, actual); + + protected override void OnValidateFail(ReadOnlyMemory expected, ReadOnlyMemory actual) + => Assert.Equal(expected, actual); + + protected override void OnValidateFail(in RedisKey expected, in RedisKey actual) + => Assert.Equal(expected, actual); + } + + [Fact] + public void BasicWrite_Bytes() + { + var resp = new XUnitTestHarness(); + resp.ValidateRouting(RedisKey.Null, "hello world"); + resp.ValidateResp( + "*2\r\n$4\r\nECHO\r\n$11\r\nhello world\r\n"u8, + "echo", + "hello world"); + } + [Fact] + public void BasicWrite_String() + { + var resp = new XUnitTestHarness(); + resp.ValidateRouting(RedisKey.Null, "hello world"); + resp.ValidateResp( + "*2\r\n$4\r\nECHO\r\n$11\r\nhello world\r\n", + "echo", + "hello world"); + } + + [Fact] + public void WithKeyPrefix() + { + var map = CommandMap.Create(new() { ["sEt"] = "put" }); + RedisKey key = "mykey"; + var resp = new XUnitTestHarness(keyPrefix: "123/", commandMap: map); + object[] args = { key, 42 }; + resp.ValidateRouting(key, args); + resp.ValidateResp("*3\r\n$3\r\nPUT\r\n$9\r\n123/mykey\r\n$2\r\n42\r\n", "set", args); + } + + [Fact] + public void WithKeyPrefix_DetectIncorrectUsage() + { + string key = "mykey"; // incorrectly not a key + var resp = new XUnitTestHarness(keyPrefix: "123/"); + object[] args = { key, 42 }; + var ex = Assert.Throws(() => resp.ValidateRouting(key, args)); + Assert.Contains("Expected: 123/mykey", ex.Message); + Assert.Contains("Actual: (null)", ex.Message); + + ex = Assert.Throws(() => resp.ValidateResp("*3\r\n$3\r\nSET\r\n$9\r\n123/mykey\r\n$2\r\n42\r\n", "set", args)); + Assert.Contains(@"Expected: ""*3\r\n$3\r\nSET\r\n$9\r\n123/mykey\r\n$2\r\n42\r\n""", ex.Message); + Assert.Contains(@"Actual: ""*3\r\n$3\r\nSET\r\n$5\r\nmykey\r\n$2\r\n42\r\n""", ex.Message); + } + + [Fact] + public void ParseExample() + { + var resp = new XUnitTestHarness(); + var result = resp.Read("*3\r\n:42\r\n#t\r\n$3\r\nabc\r\n"u8); + Assert.Equal(3, result.Length); + Assert.Equal(42, (int)result[0]); + Assert.True((bool)result[1]); + Assert.Equal("abc", (string?)result[2]); + } +} diff --git a/tests/StackExchange.Redis.Tests/TransactionTests.cs b/tests/StackExchange.Redis.Tests/TransactionTests.cs index 3a0f1e40e..06e0de700 100644 --- a/tests/StackExchange.Redis.Tests/TransactionTests.cs +++ b/tests/StackExchange.Redis.Tests/TransactionTests.cs @@ -1431,4 +1431,47 @@ public async Task ExecCompletes_Issue943() Assert.Equal(0, hashMiss); Assert.Equal(0, expireMiss); } + + [Fact] + public async Task TransactionWithFailingInnerOperation() + { + RedisKey keyA = Me() + ":A", keyB = Me() + ":B", keyC = Me() + ":C"; + await using var conn = Create(); + var db = conn.GetDatabase(); + db.StringSet(keyA, "42", flags: CommandFlags.FireAndForget); + db.StringSet(keyB, "abc", flags: CommandFlags.FireAndForget); + db.StringSet(keyC, 13, flags: CommandFlags.FireAndForget); + + var tran = db.CreateTransaction(); + var pendingA = tran.StringIncrementAsync(keyA); + var pendingB = tran.StringIncrementAsync(keyB); + var pendingC = tran.StringIncrementAsync(keyC); + Assert.True(await tran.ExecuteAsync()); + Assert.Equal(43, await pendingA); + var ex = await Assert.ThrowsAsync(() => pendingB); + Assert.Contains("ERR value is not an integer or out of range", ex.Message); + Assert.Equal(14, await pendingC); + } + + [Fact] + public async Task TransactionWithFailingCondition() + { + RedisKey keyA = Me() + ":A", keyB = Me() + ":B", keyC = Me() + ":C"; + await using var conn = Create(); + var db = conn.GetDatabase(); + db.StringSet(keyA, "42", flags: CommandFlags.FireAndForget); + db.StringSet(keyB, "abc", flags: CommandFlags.FireAndForget); + db.StringSet(keyC, 13, flags: CommandFlags.FireAndForget); + + var tran = db.CreateTransaction(); + var condition = tran.AddCondition(Condition.HashExists(keyA, "field")); + var pendingA = tran.StringIncrementAsync(keyA); + var pendingB = tran.StringIncrementAsync(keyB); + var pendingC = tran.StringIncrementAsync(keyC); + Assert.False(await tran.ExecuteAsync()); + Assert.False(condition.WasSatisfied); + Assert.Equal(TaskStatus.Canceled, pendingB.Status); + Assert.Equal(TaskStatus.Canceled, pendingB.Status); + Assert.Equal(TaskStatus.Canceled, pendingC.Status); + } } diff --git a/tests/StackExchange.Redis.Tests/WriteMode.cs b/tests/StackExchange.Redis.Tests/WriteMode.cs new file mode 100644 index 000000000..cf66c345a --- /dev/null +++ b/tests/StackExchange.Redis.Tests/WriteMode.cs @@ -0,0 +1,9 @@ +namespace StackExchange.Redis.Tests; + +public enum WriteMode +{ + Default = (int)BufferedStreamWriter.WriteMode.Default, + Sync = (int)BufferedStreamWriter.WriteMode.Sync, + Async = (int)BufferedStreamWriter.WriteMode.Async, + Pipe = (int)BufferedStreamWriter.WriteMode.Pipe, +} diff --git a/toys/StackExchange.Redis.Server/GlobalUsings.cs b/toys/StackExchange.Redis.Server/GlobalUsings.cs deleted file mode 100644 index aa3ae0946..000000000 --- a/toys/StackExchange.Redis.Server/GlobalUsings.cs +++ /dev/null @@ -1,22 +0,0 @@ -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 e27d693be..24ec9846b 100644 --- a/toys/StackExchange.Redis.Server/RedisClient.Output.cs +++ b/toys/StackExchange.Redis.Server/RedisClient.Output.cs @@ -156,7 +156,7 @@ static void WritePrefix(IBufferWriter output, char prefix) switch (type) { case RespPrefix.Integer: - PhysicalConnection.WriteInteger(output, (long)value.AsRedisValue()); + MessageWriter.WriteInteger(output, (long)value.AsRedisValue()); break; case RespPrefix.SimpleError: prefix = '-'; @@ -167,11 +167,11 @@ static void WritePrefix(IBufferWriter output, char prefix) WritePrefix(output, prefix); var val = (string)value.AsRedisValue() ?? ""; var expectedLength = Encoding.UTF8.GetByteCount(val); - PhysicalConnection.WriteRaw(output, val, expectedLength); - PhysicalConnection.WriteCrlf(output); + MessageWriter.WriteRaw(output, val, expectedLength); + MessageWriter.WriteCrlf(output); break; case RespPrefix.BulkString: - PhysicalConnection.WriteBulkString(value.AsRedisValue(), output); + MessageWriter.WriteBulkString(value.AsRedisValue(), output); break; case RespPrefix.Null: case RespPrefix.Push when value.IsNullArray: @@ -181,7 +181,7 @@ static void WritePrefix(IBufferWriter output, char prefix) output.Write("_\r\n"u8); break; case RespPrefix.Array when value.IsNullArray: - PhysicalConnection.WriteMultiBulkHeader(output, -1); + MessageWriter.WriteMultiBulkHeader(output, -1); break; case RespPrefix.Push: case RespPrefix.Map: @@ -189,7 +189,7 @@ static void WritePrefix(IBufferWriter output, char prefix) case RespPrefix.Set: case RespPrefix.Attribute: var segment = value.Span; - PhysicalConnection.WriteMultiBulkHeader(output, segment.Length, ToResultType(type)); + MessageWriter.WriteMultiBulkHeader(output, segment.Length, type); foreach (var item in segment) { if (item.IsNil) throw new InvalidOperationException("Array element cannot be nil"); @@ -209,30 +209,6 @@ static void WritePrefix(IBufferWriter output, char prefix) "Unexpected result type: " + value.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 1f09b3916..a9db75e70 100644 --- a/toys/StackExchange.Redis.Server/RedisClient.cs +++ b/toys/StackExchange.Redis.Server/RedisClient.cs @@ -10,11 +10,11 @@ namespace StackExchange.Redis.Server { public partial class RedisClient(RedisServer.Node node) : IDisposable #pragma warning disable SA1001 - #if NET +#if NET , ISpanFormattable #else , IFormattable - #endif +#endif #pragma warning restore SA1001 { private RespScanState _readState; diff --git a/toys/StackExchange.Redis.Server/RedisServer.cs b/toys/StackExchange.Redis.Server/RedisServer.cs index aa26e34a6..b2d44ca6e 100644 --- a/toys/StackExchange.Redis.Server/RedisServer.cs +++ b/toys/StackExchange.Redis.Server/RedisServer.cs @@ -174,9 +174,9 @@ public override TypedRedisValue Execute(RedisClient client, in RedisRequest requ 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; + or RedisCommand.SSUBSCRIBE or RedisCommand.SUNSUBSCRIBE + or RedisCommand.PSUBSCRIBE or RedisCommand.PUNSUBSCRIBE + or RedisCommand.PING or RedisCommand.QUIT; } [RedisCommand(2)] diff --git a/toys/StackExchange.Redis.Server/StackExchange.Redis.Server.csproj b/toys/StackExchange.Redis.Server/StackExchange.Redis.Server.csproj index 68d690497..3e800c817 100644 --- a/toys/StackExchange.Redis.Server/StackExchange.Redis.Server.csproj +++ b/toys/StackExchange.Redis.Server/StackExchange.Redis.Server.csproj @@ -12,7 +12,7 @@ $(NoWarn);CS1591 - + diff --git a/version.json b/version.json index c2ded472b..6e0824f80 100644 --- a/version.json +++ b/version.json @@ -1,7 +1,7 @@ { - "version": "2.12", + "version": "3.0", "versionHeightOffset": 0, - "assemblyVersion": "2.0", + "assemblyVersion": "3.0", "publicReleaseRefSpec": [ "^refs/heads/main$", "^refs/tags/v\\d+\\.\\d+"