From 60ac942f4793556b47e20c4c629f9772582126a8 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 22 Jan 2026 14:37:28 +0000 Subject: [PATCH 01/37] add new KeyNotification API - KeyNotification wraps channel+value, exposes friendly parsed members (db, type, etc) - KeyNotificationType is new enum for known values - add TryParseKeyNotification help to ChannelMessage (and use explicit fields) --- src/StackExchange.Redis/ChannelMessage.cs | 64 ++ .../ChannelMessageQueue.cs | 552 +++++++++--------- src/StackExchange.Redis/KeyNotification.cs | 203 +++++++ .../KeyNotificationType.cs | 69 +++ .../KeyNotificationTypeFastHash.cs | 346 +++++++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 71 ++- src/StackExchange.Redis/RedisChannel.cs | 2 + src/StackExchange.Redis/RedisValue.cs | 11 + .../FastHashTests.cs | 43 +- .../KeyNotificationTests.cs | 381 ++++++++++++ 10 files changed, 1448 insertions(+), 294 deletions(-) create mode 100644 src/StackExchange.Redis/ChannelMessage.cs create mode 100644 src/StackExchange.Redis/KeyNotification.cs create mode 100644 src/StackExchange.Redis/KeyNotificationType.cs create mode 100644 src/StackExchange.Redis/KeyNotificationTypeFastHash.cs create mode 100644 tests/StackExchange.Redis.Tests/KeyNotificationTests.cs diff --git a/src/StackExchange.Redis/ChannelMessage.cs b/src/StackExchange.Redis/ChannelMessage.cs new file mode 100644 index 000000000..330aedee4 --- /dev/null +++ b/src/StackExchange.Redis/ChannelMessage.cs @@ -0,0 +1,64 @@ +namespace StackExchange.Redis; + +/// +/// Represents a message that is broadcast via publish/subscribe. +/// +public readonly struct ChannelMessage +{ + // this is *smaller* than storing a RedisChannel for the subscribed channel + private readonly ChannelMessageQueue _queue; + + /// + /// The Channel:Message string representation. + /// + public override string ToString() => ((string?)Channel) + ":" + ((string?)Message); + + /// + public override int GetHashCode() => Channel.GetHashCode() ^ Message.GetHashCode(); + + /// + public override bool Equals(object? obj) => obj is ChannelMessage cm + && cm.Channel == Channel && cm.Message == Message; + + internal ChannelMessage(ChannelMessageQueue queue, in RedisChannel channel, in RedisValue value) + { + _queue = queue; + _channel = channel; + _message = value; + } + + /// + /// The channel that the subscription was created from. + /// + public RedisChannel SubscriptionChannel => _queue.Channel; + + private readonly RedisChannel _channel; + + /// + /// The channel that the message was broadcast to. + /// + public RedisChannel Channel => _channel; + + private readonly RedisValue _message; + + /// + /// The value that was broadcast. + /// + public RedisValue Message => _message; + + /// + /// Checks if 2 messages are .Equal(). + /// + public static bool operator ==(ChannelMessage left, ChannelMessage right) => left.Equals(right); + + /// + /// Checks if 2 messages are not .Equal(). + /// + public static bool operator !=(ChannelMessage left, ChannelMessage right) => !left.Equals(right); + + /// + /// If the channel is either a keyspace or keyevent notification, resolve the key and event type. + /// + public bool TryParseKeyNotification(out KeyNotification notification) + => KeyNotification.TryParse(in _channel, in _message, out notification); +} diff --git a/src/StackExchange.Redis/ChannelMessageQueue.cs b/src/StackExchange.Redis/ChannelMessageQueue.cs index e58fb393b..9f962e52a 100644 --- a/src/StackExchange.Redis/ChannelMessageQueue.cs +++ b/src/StackExchange.Redis/ChannelMessageQueue.cs @@ -1,385 +1,353 @@ using System; +using System.Buffers.Text; using System.Collections.Generic; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; #if NETCOREAPP3_1 +using System.Diagnostics; using System.Reflection; #endif -namespace StackExchange.Redis +namespace StackExchange.Redis; + +/// +/// Represents a message queue of ordered pub/sub notifications. +/// +/// +/// To create a ChannelMessageQueue, use +/// or . +/// +public sealed class ChannelMessageQueue : IAsyncEnumerable { + private readonly Channel _queue; + /// - /// Represents a message that is broadcast via publish/subscribe. + /// The Channel that was subscribed for this queue. /// - public readonly struct ChannelMessage - { - // this is *smaller* than storing a RedisChannel for the subscribed channel - private readonly ChannelMessageQueue _queue; + public RedisChannel Channel { get; } - /// - /// The Channel:Message string representation. - /// - public override string ToString() => ((string?)Channel) + ":" + ((string?)Message); + private RedisSubscriber? _parent; - /// - public override int GetHashCode() => Channel.GetHashCode() ^ Message.GetHashCode(); - - /// - public override bool Equals(object? obj) => obj is ChannelMessage cm - && cm.Channel == Channel && cm.Message == Message; + /// + /// The string representation of this channel. + /// + public override string? ToString() => (string?)Channel; - internal ChannelMessage(ChannelMessageQueue queue, in RedisChannel channel, in RedisValue value) - { - _queue = queue; - Channel = channel; - Message = value; - } + /// + /// An awaitable task the indicates completion of the queue (including drain of data). + /// + public Task Completion => _queue.Reader.Completion; - /// - /// The channel that the subscription was created from. - /// - public RedisChannel SubscriptionChannel => _queue.Channel; - - /// - /// The channel that the message was broadcast to. - /// - public RedisChannel Channel { get; } - - /// - /// The value that was broadcast. - /// - public RedisValue Message { get; } - - /// - /// Checks if 2 messages are .Equal(). - /// - public static bool operator ==(ChannelMessage left, ChannelMessage right) => left.Equals(right); - - /// - /// Checks if 2 messages are not .Equal(). - /// - public static bool operator !=(ChannelMessage left, ChannelMessage right) => !left.Equals(right); + internal ChannelMessageQueue(in RedisChannel redisChannel, RedisSubscriber parent) + { + Channel = redisChannel; + _parent = parent; + _queue = System.Threading.Channels.Channel.CreateUnbounded(s_ChannelOptions); } - /// - /// Represents a message queue of ordered pub/sub notifications. - /// - /// - /// To create a ChannelMessageQueue, use - /// or . - /// - public sealed class ChannelMessageQueue : IAsyncEnumerable + private static readonly UnboundedChannelOptions s_ChannelOptions = new UnboundedChannelOptions { - private readonly Channel _queue; + SingleWriter = true, SingleReader = false, AllowSynchronousContinuations = false, + }; - /// - /// The Channel that was subscribed for this queue. - /// - public RedisChannel Channel { get; } - private RedisSubscriber? _parent; + private void Write(in RedisChannel channel, in RedisValue value) + { + var writer = _queue.Writer; + writer.TryWrite(new ChannelMessage(this, channel, value)); + } - /// - /// The string representation of this channel. - /// - public override string? ToString() => (string?)Channel; + /// + /// Consume a message from the channel. + /// + /// The to use. + public ValueTask ReadAsync(CancellationToken cancellationToken = default) + => _queue.Reader.ReadAsync(cancellationToken); - /// - /// An awaitable task the indicates completion of the queue (including drain of data). - /// - public Task Completion => _queue.Reader.Completion; + /// + /// Attempt to synchronously consume a message from the channel. + /// + /// The read from the Channel. + public bool TryRead(out ChannelMessage item) => _queue.Reader.TryRead(out item); - internal ChannelMessageQueue(in RedisChannel redisChannel, RedisSubscriber parent) + /// + /// Attempt to query the backlog length of the queue. + /// + /// The (approximate) count of items in the Channel. + public bool TryGetCount(out int count) + { + // This is specific to netcoreapp3.1, because full framework was out of band and the new prop is present +#if NETCOREAPP3_1 + // get this using the reflection + try { - Channel = redisChannel; - _parent = parent; - _queue = System.Threading.Channels.Channel.CreateUnbounded(s_ChannelOptions); + var prop = + _queue.GetType().GetProperty("ItemsCountForDebugger", BindingFlags.Instance | BindingFlags.NonPublic); + if (prop is not null) + { + count = (int)prop.GetValue(_queue)!; + return true; + } } - - private static readonly UnboundedChannelOptions s_ChannelOptions = new UnboundedChannelOptions + catch (Exception ex) { - SingleWriter = true, - SingleReader = false, - AllowSynchronousContinuations = false, - }; - - private void Write(in RedisChannel channel, in RedisValue value) + Debug.WriteLine(ex.Message); // but ignore + } +#else + var reader = _queue.Reader; + if (reader.CanCount) { - var writer = _queue.Writer; - writer.TryWrite(new ChannelMessage(this, channel, value)); + count = reader.Count; + return true; } +#endif - /// - /// Consume a message from the channel. - /// - /// The to use. - public ValueTask ReadAsync(CancellationToken cancellationToken = default) - => _queue.Reader.ReadAsync(cancellationToken); - - /// - /// Attempt to synchronously consume a message from the channel. - /// - /// The read from the Channel. - public bool TryRead(out ChannelMessage item) => _queue.Reader.TryRead(out item); - - /// - /// Attempt to query the backlog length of the queue. - /// - /// The (approximate) count of items in the Channel. - public bool TryGetCount(out int count) + count = 0; + return false; + } + + private Delegate? _onMessageHandler; + + private void AssertOnMessage(Delegate handler) + { + if (handler == null) throw new ArgumentNullException(nameof(handler)); + if (Interlocked.CompareExchange(ref _onMessageHandler, handler, null) != null) + throw new InvalidOperationException("Only a single " + nameof(OnMessage) + " is allowed"); + } + + /// + /// Create a message loop that processes messages sequentially. + /// + /// The handler to run when receiving a message. + public void OnMessage(Action handler) + { + AssertOnMessage(handler); + + ThreadPool.QueueUserWorkItem( + state => ((ChannelMessageQueue)state!).OnMessageSyncImpl().RedisFireAndForget(), this); + } + + private async Task OnMessageSyncImpl() + { + var handler = (Action?)_onMessageHandler; + while (!Completion.IsCompleted) { - // This is specific to netcoreapp3.1, because full framework was out of band and the new prop is present -#if NETCOREAPP3_1 - // get this using the reflection + ChannelMessage next; try { - var prop = _queue.GetType().GetProperty("ItemsCountForDebugger", BindingFlags.Instance | BindingFlags.NonPublic); - if (prop is not null) - { - count = (int)prop.GetValue(_queue)!; - return true; - } + if (!TryRead(out next)) next = await ReadAsync().ForAwait(); } - catch { } -#else - var reader = _queue.Reader; - if (reader.CanCount) + catch (ChannelClosedException) { break; } // expected + catch (Exception ex) { - count = reader.Count; - return true; + _parent?.multiplexer?.OnInternalError(ex); + break; } -#endif - count = default; - return false; + try { handler?.Invoke(next); } + catch { } // matches MessageCompletable } + } - private Delegate? _onMessageHandler; - private void AssertOnMessage(Delegate handler) + internal static void Combine(ref ChannelMessageQueue? head, ChannelMessageQueue queue) + { + if (queue != null) { - if (handler == null) throw new ArgumentNullException(nameof(handler)); - if (Interlocked.CompareExchange(ref _onMessageHandler, handler, null) != null) - throw new InvalidOperationException("Only a single " + nameof(OnMessage) + " is allowed"); + // insert at the start of the linked-list + ChannelMessageQueue? old; + do + { + old = Volatile.Read(ref head); + queue._next = old; + } + // format and validator disagree on newline... + while (Interlocked.CompareExchange(ref head, queue, old) != old); } + } - /// - /// Create a message loop that processes messages sequentially. - /// - /// The handler to run when receiving a message. - public void OnMessage(Action handler) - { - AssertOnMessage(handler); + /// + /// Create a message loop that processes messages sequentially. + /// + /// The handler to execute when receiving a message. + public void OnMessage(Func handler) + { + AssertOnMessage(handler); - ThreadPool.QueueUserWorkItem( - state => ((ChannelMessageQueue)state!).OnMessageSyncImpl().RedisFireAndForget(), this); - } + ThreadPool.QueueUserWorkItem( + state => ((ChannelMessageQueue)state!).OnMessageAsyncImpl().RedisFireAndForget(), this); + } - private async Task OnMessageSyncImpl() + internal static void Remove(ref ChannelMessageQueue? head, ChannelMessageQueue queue) + { + if (queue is null) { - var handler = (Action?)_onMessageHandler; - while (!Completion.IsCompleted) - { - ChannelMessage next; - try { if (!TryRead(out next)) next = await ReadAsync().ForAwait(); } - catch (ChannelClosedException) { break; } // expected - catch (Exception ex) - { - _parent?.multiplexer?.OnInternalError(ex); - break; - } - - try { handler?.Invoke(next); } - catch { } // matches MessageCompletable - } + return; } - internal static void Combine(ref ChannelMessageQueue? head, ChannelMessageQueue queue) + bool found; + // if we fail due to a conflict, re-do from start + do { - if (queue != null) + var current = Volatile.Read(ref head); + if (current == null) return; // no queue? nothing to do + if (current == queue) { - // insert at the start of the linked-list - ChannelMessageQueue? old; - do + found = true; + // found at the head - then we need to change the head + if (Interlocked.CompareExchange(ref head, Volatile.Read(ref current._next), current) == current) { - old = Volatile.Read(ref head); - queue._next = old; + return; // success } - while (Interlocked.CompareExchange(ref head, queue, old) != old); } - } - - /// - /// Create a message loop that processes messages sequentially. - /// - /// The handler to execute when receiving a message. - public void OnMessage(Func handler) - { - AssertOnMessage(handler); - - ThreadPool.QueueUserWorkItem( - state => ((ChannelMessageQueue)state!).OnMessageAsyncImpl().RedisFireAndForget(), this); - } - - internal static void Remove(ref ChannelMessageQueue? head, ChannelMessageQueue queue) - { - if (queue is null) + else { - return; - } - - bool found; - // if we fail due to a conflict, re-do from start - do - { - var current = Volatile.Read(ref head); - if (current == null) return; // no queue? nothing to do - if (current == queue) - { - found = true; - // found at the head - then we need to change the head - if (Interlocked.CompareExchange(ref head, Volatile.Read(ref current._next), current) == current) - { - return; // success - } - } - else + ChannelMessageQueue? previous = current; + current = Volatile.Read(ref previous._next); + found = false; + do { - ChannelMessageQueue? previous = current; - current = Volatile.Read(ref previous._next); - found = false; - do + if (current == queue) { - if (current == queue) + found = true; + // found it, not at the head; remove the node + if (Interlocked.CompareExchange( + ref previous._next, + Volatile.Read(ref current._next), + current) == current) { - found = true; - // found it, not at the head; remove the node - if (Interlocked.CompareExchange(ref previous._next, Volatile.Read(ref current._next), current) == current) - { - return; // success - } - else - { - break; // exit the inner loop, and repeat the outer loop - } + return; // success + } + else + { + break; // exit the inner loop, and repeat the outer loop } - previous = current; - current = Volatile.Read(ref previous!._next); } - while (current != null); + + previous = current; + current = Volatile.Read(ref previous!._next); } + // format and validator disagree on newline... + while (current != null); } - while (found); } + // format and validator disagree on newline... + while (found); + } - internal static int Count(ref ChannelMessageQueue? head) + internal static int Count(ref ChannelMessageQueue? head) + { + var current = Volatile.Read(ref head); + int count = 0; + while (current != null) { - var current = Volatile.Read(ref head); - int count = 0; - while (current != null) - { - count++; - current = Volatile.Read(ref current._next); - } - return count; + count++; + current = Volatile.Read(ref current._next); } - internal static void WriteAll(ref ChannelMessageQueue head, in RedisChannel channel, in RedisValue message) + return count; + } + + internal static void WriteAll(ref ChannelMessageQueue head, in RedisChannel channel, in RedisValue message) + { + var current = Volatile.Read(ref head); + while (current != null) { - var current = Volatile.Read(ref head); - while (current != null) - { - current.Write(channel, message); - current = Volatile.Read(ref current._next); - } + current.Write(channel, message); + current = Volatile.Read(ref current._next); } + } - private ChannelMessageQueue? _next; + private ChannelMessageQueue? _next; - private async Task OnMessageAsyncImpl() + private async Task OnMessageAsyncImpl() + { + var handler = (Func?)_onMessageHandler; + while (!Completion.IsCompleted) { - var handler = (Func?)_onMessageHandler; - while (!Completion.IsCompleted) + ChannelMessage next; + try { - ChannelMessage next; - try { if (!TryRead(out next)) next = await ReadAsync().ForAwait(); } - catch (ChannelClosedException) { break; } // expected - catch (Exception ex) - { - _parent?.multiplexer?.OnInternalError(ex); - break; - } - - try - { - var task = handler?.Invoke(next); - if (task != null && task.Status != TaskStatus.RanToCompletion) await task.ForAwait(); - } - catch { } // matches MessageCompletable + if (!TryRead(out next)) next = await ReadAsync().ForAwait(); + } + catch (ChannelClosedException) { break; } // expected + catch (Exception ex) + { + _parent?.multiplexer?.OnInternalError(ex); + break; } - } - internal static void MarkAllCompleted(ref ChannelMessageQueue? head) - { - var current = Interlocked.Exchange(ref head, null); - while (current != null) + try { - current.MarkCompleted(); - current = Volatile.Read(ref current._next); + var task = handler?.Invoke(next); + if (task != null && task.Status != TaskStatus.RanToCompletion) await task.ForAwait(); } + catch { } // matches MessageCompletable } + } - private void MarkCompleted(Exception? error = null) + internal static void MarkAllCompleted(ref ChannelMessageQueue? head) + { + var current = Interlocked.Exchange(ref head, null); + while (current != null) { - _parent = null; - _queue.Writer.TryComplete(error); + current.MarkCompleted(); + current = Volatile.Read(ref current._next); } + } - internal void UnsubscribeImpl(Exception? error = null, CommandFlags flags = CommandFlags.None) - { - var parent = _parent; - _parent = null; - parent?.UnsubscribeAsync(Channel, null, this, flags); - _queue.Writer.TryComplete(error); - } + private void MarkCompleted(Exception? error = null) + { + _parent = null; + _queue.Writer.TryComplete(error); + } - internal async Task UnsubscribeAsyncImpl(Exception? error = null, CommandFlags flags = CommandFlags.None) + internal void UnsubscribeImpl(Exception? error = null, CommandFlags flags = CommandFlags.None) + { + var parent = _parent; + _parent = null; + parent?.UnsubscribeAsync(Channel, null, this, flags); + _queue.Writer.TryComplete(error); + } + + internal async Task UnsubscribeAsyncImpl(Exception? error = null, CommandFlags flags = CommandFlags.None) + { + var parent = _parent; + _parent = null; + if (parent != null) { - var parent = _parent; - _parent = null; - if (parent != null) - { - await parent.UnsubscribeAsync(Channel, null, this, flags).ForAwait(); - } - _queue.Writer.TryComplete(error); + await parent.UnsubscribeAsync(Channel, null, this, flags).ForAwait(); } - /// - /// Stop receiving messages on this channel. - /// - /// The flags to use when unsubscribing. - public void Unsubscribe(CommandFlags flags = CommandFlags.None) => UnsubscribeImpl(null, flags); + _queue.Writer.TryComplete(error); + } - /// - /// Stop receiving messages on this channel. - /// - /// The flags to use when unsubscribing. - public Task UnsubscribeAsync(CommandFlags flags = CommandFlags.None) => UnsubscribeAsyncImpl(null, flags); + /// + /// Stop receiving messages on this channel. + /// + /// The flags to use when unsubscribing. + public void Unsubscribe(CommandFlags flags = CommandFlags.None) => UnsubscribeImpl(null, flags); - /// + /// + /// Stop receiving messages on this channel. + /// + /// The flags to use when unsubscribing. + public Task UnsubscribeAsync(CommandFlags flags = CommandFlags.None) => UnsubscribeAsyncImpl(null, flags); + + /// #if NETCOREAPP3_0_OR_GREATER - public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) - => _queue.Reader.ReadAllAsync().GetAsyncEnumerator(cancellationToken); + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + // ReSharper disable once MethodSupportsCancellation - provided in GetAsyncEnumerator + => _queue.Reader.ReadAllAsync().GetAsyncEnumerator(cancellationToken); #else - public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + while (await _queue.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) { - while (await _queue.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) + while (_queue.Reader.TryRead(out var item)) { - while (_queue.Reader.TryRead(out var item)) - { - yield return item; - } + yield return item; } } -#endif } +#endif } diff --git a/src/StackExchange.Redis/KeyNotification.cs b/src/StackExchange.Redis/KeyNotification.cs new file mode 100644 index 000000000..47e9f287e --- /dev/null +++ b/src/StackExchange.Redis/KeyNotification.cs @@ -0,0 +1,203 @@ +using System; +using System.Buffers.Text; +using System.Diagnostics; + +namespace StackExchange.Redis; + +/// +/// Represents keyspace and keyevent notifications. +/// +public readonly struct KeyNotification +{ + /// + /// If the channel is either a keyspace or keyevent notification, parsed the data. + /// + public static bool TryParse(in RedisChannel channel, in RedisValue value, out KeyNotification notification) + { + // validate that it looks reasonable + var span = channel.Span; + if (span.StartsWith("__keyspace@"u8) || span.StartsWith("__keyevent@"u8)) + { + // check that there is *something* non-empty after the prefix, with __: as the suffix (we don't verify *what*) + if (span.Slice(11).IndexOf("__:"u8) > 0) + { + notification = new KeyNotification(in channel, in value); + return true; + } + } + + notification = default; + return false; + } + + /// + /// The channel associated with this notification. + /// + public RedisChannel Channel => _channel; + + /// + /// The payload associated with this notification. + /// + public RedisValue Value => _value; + + // effectively we just wrap a channel, but: we've pre-validated that things make sense + private readonly RedisChannel _channel; + private readonly RedisValue _value; + + internal KeyNotification(in RedisChannel channel, in RedisValue value) + { + _channel = channel; + _value = value; + } + + /// + /// The database the key is in. If the database cannot be parsed, -1 is returned. + /// + public int Database + { + get + { + // prevalidated format, so we can just skip past the prefix (except for the default value) + if (_channel.IsNull) return -1; + var span = _channel.Span.Slice(11); + var end = span.IndexOf((byte)'_'); // expecting __: + if (end <= 0) return -1; + + span = span.Slice(0, end); + return Utf8Parser.TryParse(span, out int database, out var bytes) + && bytes == end ? database : -1; + } + } + + /// + /// The key associated with this event. + /// + /// Note that this will allocate a copy of the key bytes; to avoid allocations, + /// the and APIs can be used. + public RedisKey GetKey() + { + if (IsKeySpace) + { + // then the channel contains the key, and the payload contains the event-type + return ChannelSuffix.ToArray(); // create an isolated copy + } + + if (IsKeyEvent) + { + // then the channel contains the event-type, and the payload contains the key + return (byte[]?)Value; // todo: this could probably side-step + } + + return RedisKey.Null; + } + + /// + /// Get the number of bytes in the key. + /// + public int KeyByteCount + { + get + { + if (IsKeySpace) + { + return ChannelSuffix.Length; + } + + if (IsKeyEvent) + { + return _value.GetByteCount(); + } + + return 0; + } + } + + /// + /// Attempt to copy the bytes from the key to a buffer, returning the number of bytes written. + /// + public bool TryCopyKey(Span destination, out int bytesWritten) + { + if (IsKeySpace) + { + var suffix = ChannelSuffix; + bytesWritten = suffix.Length; // assume success + if (bytesWritten <= destination.Length) + { + suffix.CopyTo(destination); + return true; + } + } + + if (IsKeyEvent) + { + bytesWritten = _value.GetByteCount(); + if (bytesWritten <= destination.Length) + { + var tmp = _value.CopyTo(destination); + Debug.Assert(tmp == bytesWritten); + return true; + } + } + + bytesWritten = 0; + return false; + } + + /// + /// Get the portion of the channel after the "__{keyspace|keyevent}@{db}__:". + /// + private ReadOnlySpan ChannelSuffix + { + get + { + var span = _channel.Span; + var index = span.IndexOf("__:"u8); + return index > 0 ? span.Slice(index + 3) : default; + } + } + + /// + /// The type of notification associated with this event, if it is well-known - otherwise . + /// + /// Unexpected values can be processed manually from the and . + public KeyNotificationType Type + { + get + { + if (IsKeySpace) + { + // then the channel contains the key, and the payload contains the event-type + var count = _value.GetByteCount(); + if (count >= KeyNotificationTypeFastHash.MinBytes & count <= KeyNotificationTypeFastHash.MaxBytes) + { + if (_value.TryGetSpan(out var direct)) + { + return KeyNotificationTypeFastHash.Parse(direct); + } + else + { + Span localCopy = stackalloc byte[KeyNotificationTypeFastHash.MaxBytes]; + return KeyNotificationTypeFastHash.Parse(localCopy.Slice(0, _value.CopyTo(localCopy))); + } + } + } + + if (IsKeyEvent) + { + // then the channel contains the event-type, and the payload contains the key + return KeyNotificationTypeFastHash.Parse(ChannelSuffix); + } + return KeyNotificationType.Unknown; + } + } + + /// + /// Indicates whether this notification originated from a keyspace notification, for example __keyspace@0__:mykey with payload set. + /// + public bool IsKeySpace => _channel.Span.StartsWith("__keyspace@"u8); + + /// + /// Indicates whether this notification originated from a keyevent notification, for example __keyevent@0__:set with payload mykey. + /// + public bool IsKeyEvent => _channel.Span.StartsWith("__keyevent@"u8); +} diff --git a/src/StackExchange.Redis/KeyNotificationType.cs b/src/StackExchange.Redis/KeyNotificationType.cs new file mode 100644 index 000000000..159a518a4 --- /dev/null +++ b/src/StackExchange.Redis/KeyNotificationType.cs @@ -0,0 +1,69 @@ +namespace StackExchange.Redis; + +/// +/// The type of keyspace or keyevent notification. +/// +public enum KeyNotificationType +{ + // note: initially presented alphabetically, but: new values *must* be appended, not inserted + // (to preserve values of existing elements) +#pragma warning disable CS1591 // docs, redundant + Unknown = 0, + Append = 1, + Copy = 1, + Del = 2, + Expire = 3, + HDel = 4, + HExpired = 5, + HIncrByFloat = 6, + HIncrBy = 7, + HPersist = 8, + HSet = 9, + IncrByFloat = 10, + IncrBy = 11, + LInsert = 12, + LPop = 13, + LPush = 14, + LRem = 15, + LSet = 16, + LTrim = 17, + MoveFrom = 18, + MoveTo = 19, + Persist = 20, + RenameFrom = 21, + RenameTo = 22, + Restore = 23, + RPop = 24, + RPush = 25, + SAdd = 26, + Set = 27, + SetRange = 28, + SortStore = 29, + SRem = 30, + SPop = 31, + XAdd = 32, + XDel = 33, + XGroupCreateConsumer = 34, + XGroupCreate = 35, + XGroupDelConsumer = 36, + XGroupDestroy = 37, + XGroupSetId = 38, + XSetId = 39, + XTrim = 40, + ZAdd = 41, + ZDiffStore = 42, + ZInterStore = 43, + ZUnionStore = 44, + ZIncr = 45, + ZRemByRank = 46, + ZRemByScore = 47, + ZRem = 48, + + // side-effect notifications + Expired = 1000, + Evicted = 1001, + New = 1002, + Overwritten = 1003, + TypeChanged = 1004, // type_changed +#pragma warning restore CS1591 // docs, redundant +} diff --git a/src/StackExchange.Redis/KeyNotificationTypeFastHash.cs b/src/StackExchange.Redis/KeyNotificationTypeFastHash.cs new file mode 100644 index 000000000..bae3f4944 --- /dev/null +++ b/src/StackExchange.Redis/KeyNotificationTypeFastHash.cs @@ -0,0 +1,346 @@ +using System; + +namespace StackExchange.Redis; + +internal static partial class KeyNotificationTypeFastHash +{ + // these are checked by KeyNotificationTypeFastHash_MinMaxBytes_ReflectsActualLengths + public const int MinBytes = 3, MaxBytes = 21; + + public static KeyNotificationType Parse(ReadOnlySpan value) + { + var hash = value.Hash64(); + return hash switch + { + append.Hash when append.Is(hash, value) => KeyNotificationType.Append, + copy.Hash when copy.Is(hash, value) => KeyNotificationType.Copy, + del.Hash when del.Is(hash, value) => KeyNotificationType.Del, + expire.Hash when expire.Is(hash, value) => KeyNotificationType.Expire, + hdel.Hash when hdel.Is(hash, value) => KeyNotificationType.HDel, + hexpired.Hash when hexpired.Is(hash, value) => KeyNotificationType.HExpired, + hincrbyfloat.Hash when hincrbyfloat.Is(hash, value) => KeyNotificationType.HIncrByFloat, + hincrby.Hash when hincrby.Is(hash, value) => KeyNotificationType.HIncrBy, + hpersist.Hash when hpersist.Is(hash, value) => KeyNotificationType.HPersist, + hset.Hash when hset.Is(hash, value) => KeyNotificationType.HSet, + incrbyfloat.Hash when incrbyfloat.Is(hash, value) => KeyNotificationType.IncrByFloat, + incrby.Hash when incrby.Is(hash, value) => KeyNotificationType.IncrBy, + linsert.Hash when linsert.Is(hash, value) => KeyNotificationType.LInsert, + lpop.Hash when lpop.Is(hash, value) => KeyNotificationType.LPop, + lpush.Hash when lpush.Is(hash, value) => KeyNotificationType.LPush, + lrem.Hash when lrem.Is(hash, value) => KeyNotificationType.LRem, + lset.Hash when lset.Is(hash, value) => KeyNotificationType.LSet, + ltrim.Hash when ltrim.Is(hash, value) => KeyNotificationType.LTrim, + move_from.Hash when move_from.Is(hash, value) => KeyNotificationType.MoveFrom, + move_to.Hash when move_to.Is(hash, value) => KeyNotificationType.MoveTo, + persist.Hash when persist.Is(hash, value) => KeyNotificationType.Persist, + rename_from.Hash when rename_from.Is(hash, value) => KeyNotificationType.RenameFrom, + rename_to.Hash when rename_to.Is(hash, value) => KeyNotificationType.RenameTo, + restore.Hash when restore.Is(hash, value) => KeyNotificationType.Restore, + rpop.Hash when rpop.Is(hash, value) => KeyNotificationType.RPop, + rpush.Hash when rpush.Is(hash, value) => KeyNotificationType.RPush, + sadd.Hash when sadd.Is(hash, value) => KeyNotificationType.SAdd, + set.Hash when set.Is(hash, value) => KeyNotificationType.Set, + setrange.Hash when setrange.Is(hash, value) => KeyNotificationType.SetRange, + sortstore.Hash when sortstore.Is(hash, value) => KeyNotificationType.SortStore, + srem.Hash when srem.Is(hash, value) => KeyNotificationType.SRem, + spop.Hash when spop.Is(hash, value) => KeyNotificationType.SPop, + xadd.Hash when xadd.Is(hash, value) => KeyNotificationType.XAdd, + xdel.Hash when xdel.Is(hash, value) => KeyNotificationType.XDel, + xgroupcreateconsumer.Hash when xgroupcreateconsumer.Is(hash, value) => KeyNotificationType.XGroupCreateConsumer, + xgroupcreate.Hash when xgroupcreate.Is(hash, value) => KeyNotificationType.XGroupCreate, + xgroupdelconsumer.Hash when xgroupdelconsumer.Is(hash, value) => KeyNotificationType.XGroupDelConsumer, + xgroupdestroy.Hash when xgroupdestroy.Is(hash, value) => KeyNotificationType.XGroupDestroy, + xgroupsetid.Hash when xgroupsetid.Is(hash, value) => KeyNotificationType.XGroupSetId, + xsetid.Hash when xsetid.Is(hash, value) => KeyNotificationType.XSetId, + xtrim.Hash when xtrim.Is(hash, value) => KeyNotificationType.XTrim, + zadd.Hash when zadd.Is(hash, value) => KeyNotificationType.ZAdd, + zdiffstore.Hash when zdiffstore.Is(hash, value) => KeyNotificationType.ZDiffStore, + zinterstore.Hash when zinterstore.Is(hash, value) => KeyNotificationType.ZInterStore, + zunionstore.Hash when zunionstore.Is(hash, value) => KeyNotificationType.ZUnionStore, + zincr.Hash when zincr.Is(hash, value) => KeyNotificationType.ZIncr, + zrembyrank.Hash when zrembyrank.Is(hash, value) => KeyNotificationType.ZRemByRank, + zrembyscore.Hash when zrembyscore.Is(hash, value) => KeyNotificationType.ZRemByScore, + zrem.Hash when zrem.Is(hash, value) => KeyNotificationType.ZRem, + expired.Hash when expired.Is(hash, value) => KeyNotificationType.Expired, + evicted.Hash when evicted.Is(hash, value) => KeyNotificationType.Evicted, + _new.Hash when _new.Is(hash, value) => KeyNotificationType.New, + overwritten.Hash when overwritten.Is(hash, value) => KeyNotificationType.Overwritten, + type_changed.Hash when type_changed.Is(hash, value) => KeyNotificationType.TypeChanged, + _ => KeyNotificationType.Unknown, + }; + } +#pragma warning disable SA1300, CS8981 + // ReSharper disable InconsistentNaming + [FastHash] + internal static partial class append + { + } + + [FastHash] + internal static partial class copy + { + } + + [FastHash] + internal static partial class del + { + } + + [FastHash] + internal static partial class expire + { + } + + [FastHash] + internal static partial class hdel + { + } + + [FastHash] + internal static partial class hexpired + { + } + + [FastHash] + internal static partial class hincrbyfloat + { + } + + [FastHash] + internal static partial class hincrby + { + } + + [FastHash] + internal static partial class hpersist + { + } + + [FastHash] + internal static partial class hset + { + } + + [FastHash] + internal static partial class incrbyfloat + { + } + + [FastHash] + internal static partial class incrby + { + } + + [FastHash] + internal static partial class linsert + { + } + + [FastHash] + internal static partial class lpop + { + } + + [FastHash] + internal static partial class lpush + { + } + + [FastHash] + internal static partial class lrem + { + } + + [FastHash] + internal static partial class lset + { + } + + [FastHash] + internal static partial class ltrim + { + } + + [FastHash("move_from")] + internal static partial class move_from + { + } + + [FastHash("move_to")] + internal static partial class move_to + { + } + + [FastHash] + internal static partial class persist + { + } + + [FastHash("rename_from")] + internal static partial class rename_from + { + } + + [FastHash("rename_to")] + internal static partial class rename_to + { + } + + [FastHash] + internal static partial class restore + { + } + + [FastHash] + internal static partial class rpop + { + } + + [FastHash] + internal static partial class rpush + { + } + + [FastHash] + internal static partial class sadd + { + } + + [FastHash] + internal static partial class set + { + } + + [FastHash] + internal static partial class setrange + { + } + + [FastHash] + internal static partial class sortstore + { + } + + [FastHash] + internal static partial class srem + { + } + + [FastHash] + internal static partial class spop + { + } + + [FastHash] + internal static partial class xadd + { + } + + [FastHash] + internal static partial class xdel + { + } + + [FastHash("xgroup-createconsumer")] + internal static partial class xgroupcreateconsumer + { + } + + [FastHash("xgroup-create")] + internal static partial class xgroupcreate + { + } + + [FastHash("xgroup-delconsumer")] + internal static partial class xgroupdelconsumer + { + } + + [FastHash("xgroup-destroy")] + internal static partial class xgroupdestroy + { + } + + [FastHash("xgroup-setid")] + internal static partial class xgroupsetid + { + } + + [FastHash] + internal static partial class xsetid + { + } + + [FastHash] + internal static partial class xtrim + { + } + + [FastHash] + internal static partial class zadd + { + } + + [FastHash] + internal static partial class zdiffstore + { + } + + [FastHash] + internal static partial class zinterstore + { + } + + [FastHash] + internal static partial class zunionstore + { + } + + [FastHash] + internal static partial class zincr + { + } + + [FastHash] + internal static partial class zrembyrank + { + } + + [FastHash] + internal static partial class zrembyscore + { + } + + [FastHash] + internal static partial class zrem + { + } + + [FastHash] + internal static partial class expired + { + } + + [FastHash] + internal static partial class evicted + { + } + + [FastHash("new")] + internal static partial class _new // it isn't worth making the code-gen keyword aware + { + } + + [FastHash] + internal static partial class overwritten + { + } + + [FastHash("type_changed")] // by default, the generator interprets underscore as hyphen + internal static partial class type_changed + { + } + + // ReSharper restore InconsistentNaming +#pragma warning restore SA1300, CS8981 +} diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 91b0e1a43..4a767fe9b 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,70 @@ -#nullable enable \ No newline at end of file +#nullable enable +StackExchange.Redis.KeyNotification +StackExchange.Redis.KeyNotification.Channel.get -> StackExchange.Redis.RedisChannel +StackExchange.Redis.KeyNotification.Value.get -> StackExchange.Redis.RedisValue +StackExchange.Redis.KeyNotification.Database.get -> int +StackExchange.Redis.KeyNotification.GetKey() -> StackExchange.Redis.RedisKey +StackExchange.Redis.KeyNotification.IsKeyEvent.get -> bool +StackExchange.Redis.KeyNotification.IsKeySpace.get -> bool +StackExchange.Redis.KeyNotification.KeyByteCount.get -> int +StackExchange.Redis.KeyNotification.KeyNotification() -> void +StackExchange.Redis.KeyNotification.TryCopyKey(System.Span destination, out int bytesWritten) -> bool +StackExchange.Redis.KeyNotification.Type.get -> StackExchange.Redis.KeyNotificationType +static StackExchange.Redis.KeyNotification.TryParse(in StackExchange.Redis.RedisChannel channel, in StackExchange.Redis.RedisValue value, out StackExchange.Redis.KeyNotification notification) -> bool +StackExchange.Redis.ChannelMessage.TryParseKeyNotification(out StackExchange.Redis.KeyNotification notification) -> bool +StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.Append = 1 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.Copy = 1 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.Del = 2 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.Evicted = 1001 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.Expire = 3 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.Expired = 1000 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.HDel = 4 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.HExpired = 5 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.HIncrBy = 7 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.HIncrByFloat = 6 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.HPersist = 8 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.HSet = 9 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.IncrBy = 11 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.IncrByFloat = 10 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.LInsert = 12 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.LPop = 13 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.LPush = 14 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.LRem = 15 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.LSet = 16 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.LTrim = 17 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.MoveFrom = 18 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.MoveTo = 19 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.New = 1002 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.Overwritten = 1003 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.Persist = 20 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.RenameFrom = 21 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.RenameTo = 22 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.Restore = 23 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.RPop = 24 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.RPush = 25 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.SAdd = 26 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.Set = 27 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.SetRange = 28 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.SortStore = 29 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.SPop = 31 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.SRem = 30 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.TypeChanged = 1004 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.Unknown = 0 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.XAdd = 32 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.XDel = 33 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.XGroupCreate = 35 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.XGroupCreateConsumer = 34 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.XGroupDelConsumer = 36 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.XGroupDestroy = 37 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.XGroupSetId = 38 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.XSetId = 39 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.XTrim = 40 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ZAdd = 41 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ZDiffStore = 42 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ZIncr = 45 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ZInterStore = 43 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ZRem = 48 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ZRemByRank = 46 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ZRemByScore = 47 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ZUnionStore = 44 -> StackExchange.Redis.KeyNotificationType \ No newline at end of file diff --git a/src/StackExchange.Redis/RedisChannel.cs b/src/StackExchange.Redis/RedisChannel.cs index d4289f3c6..8e8373022 100644 --- a/src/StackExchange.Redis/RedisChannel.cs +++ b/src/StackExchange.Redis/RedisChannel.cs @@ -10,6 +10,8 @@ namespace StackExchange.Redis { internal readonly byte[]? Value; + internal ReadOnlySpan Span => Value is null ? default : Value.AsSpan(); + internal readonly RedisChannelOptions Options; [Flags] diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index d306ca0d0..1f6947460 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -1245,5 +1245,16 @@ internal ValueCondition Digest() return digest; } } + + internal bool TryGetSpan(out ReadOnlySpan span) + { + if (_objectOrSentinel == Sentinel_Raw) + { + span = _memory.Span; + return true; + } + span = default; + return false; + } } } diff --git a/tests/StackExchange.Redis.Tests/FastHashTests.cs b/tests/StackExchange.Redis.Tests/FastHashTests.cs index 418198cfd..a032cfc80 100644 --- a/tests/StackExchange.Redis.Tests/FastHashTests.cs +++ b/tests/StackExchange.Redis.Tests/FastHashTests.cs @@ -2,13 +2,14 @@ using System.Runtime.InteropServices; using System.Text; using Xunit; +using Xunit.Sdk; #pragma warning disable CS8981, SA1134, SA1300, SA1303, SA1502 // names are weird in this test! // ReSharper disable InconsistentNaming - to better represent expected literals // ReSharper disable IdentifierTypo namespace StackExchange.Redis.Tests; -public partial class FastHashTests +public partial class FastHashTests(ITestOutputHelper log) { // note: if the hashing algorithm changes, we can update the last parameter freely; it doesn't matter // what it *is* - what matters is that we can see that it has entropy between different values @@ -83,6 +84,46 @@ public void FastHashIs_Long() Assert.False(abcdefghijklmnopqrst.Is(hash, value)); } + [Fact] + public void KeyNotificationTypeFastHash_MinMaxBytes_ReflectsActualLengths() + { + // Use reflection to find all nested types in KeyNotificationTypeFastHash + var fastHashType = typeof(KeyNotificationTypeFastHash); + var nestedTypes = fastHashType.GetNestedTypes(System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + int? minLength = null; + int? maxLength = null; + + foreach (var nestedType in nestedTypes) + { + // Look for the Length field (generated by FastHash source generator) + var lengthField = nestedType.GetField("Length", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + if (lengthField != null && lengthField.FieldType == typeof(int)) + { + var length = (int)lengthField.GetValue(null)!; + + if (minLength == null || length < minLength) + { + minLength = length; + } + + if (maxLength == null || length > maxLength) + { + maxLength = length; + } + } + } + + // Assert that we found at least some nested types with Length fields + Assert.NotNull(minLength); + Assert.NotNull(maxLength); + + // Assert that MinBytes and MaxBytes match the actual min/max lengths + log.WriteLine($"MinBytes: {KeyNotificationTypeFastHash.MinBytes}, MaxBytes: {KeyNotificationTypeFastHash.MaxBytes}"); + Assert.Equal(KeyNotificationTypeFastHash.MinBytes, minLength.Value); + Assert.Equal(KeyNotificationTypeFastHash.MaxBytes, maxLength.Value); + } + [FastHash] private static partial class a { } [FastHash] private static partial class ab { } [FastHash] private static partial class abc { } diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs new file mode 100644 index 000000000..6b0a32bbf --- /dev/null +++ b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs @@ -0,0 +1,381 @@ +using System; +using System.Buffers; +using System.Text; +using Xunit; + +namespace StackExchange.Redis.Tests; + +public class KeyNotificationTests +{ + [Fact] + public void Keyspace_Del_ParsesCorrectly() + { + // __keyspace@1__:mykey with payload "del" + var channel = RedisChannel.Literal("__keyspace@1__:mykey"); + RedisValue value = "del"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.True(notification.IsKeySpace); + Assert.False(notification.IsKeyEvent); + Assert.Equal(1, notification.Database); + Assert.Equal(KeyNotificationType.Del, notification.Type); + Assert.Equal("mykey", (string?)notification.GetKey()); + Assert.Equal(5, notification.KeyByteCount); + } + + [Fact] + public void Keyevent_Del_ParsesCorrectly() + { + // __keyevent@42__:del with value "mykey" + var channel = RedisChannel.Literal("__keyevent@42__:del"); + RedisValue value = "mykey"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.False(notification.IsKeySpace); + Assert.True(notification.IsKeyEvent); + Assert.Equal(42, notification.Database); + Assert.Equal(KeyNotificationType.Del, notification.Type); + Assert.Equal("mykey", (string?)notification.GetKey()); + Assert.Equal(5, notification.KeyByteCount); + } + + [Fact] + public void Keyspace_Set_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyspace@0__:testkey"); + RedisValue value = "set"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.True(notification.IsKeySpace); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.Set, notification.Type); + Assert.Equal("testkey", (string?)notification.GetKey()); + } + + [Fact] + public void Keyevent_Expire_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyevent@5__:expire"); + RedisValue value = "session:12345"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.True(notification.IsKeyEvent); + Assert.Equal(5, notification.Database); + Assert.Equal(KeyNotificationType.Expire, notification.Type); + Assert.Equal("session:12345", (string?)notification.GetKey()); + } + + [Fact] + public void Keyspace_Expired_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyspace@3__:cache:item"); + RedisValue value = "expired"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.True(notification.IsKeySpace); + Assert.Equal(3, notification.Database); + Assert.Equal(KeyNotificationType.Expired, notification.Type); + Assert.Equal("cache:item", (string?)notification.GetKey()); + } + + [Fact] + public void Keyevent_LPush_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyevent@0__:lpush"); + RedisValue value = "queue:tasks"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.True(notification.IsKeyEvent); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.LPush, notification.Type); + Assert.Equal("queue:tasks", (string?)notification.GetKey()); + } + + [Fact] + public void Keyspace_HSet_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyspace@2__:user:1000"); + RedisValue value = "hset"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.True(notification.IsKeySpace); + Assert.Equal(2, notification.Database); + Assert.Equal(KeyNotificationType.HSet, notification.Type); + Assert.Equal("user:1000", (string?)notification.GetKey()); + } + + [Fact] + public void Keyevent_ZAdd_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyevent@7__:zadd"); + RedisValue value = "leaderboard"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.True(notification.IsKeyEvent); + Assert.Equal(7, notification.Database); + Assert.Equal(KeyNotificationType.ZAdd, notification.Type); + Assert.Equal("leaderboard", (string?)notification.GetKey()); + } + + [Fact] + public void TryCopyKey_WorksCorrectly() + { + var channel = RedisChannel.Literal("__keyspace@0__:testkey"); + RedisValue value = "set"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + var lease = ArrayPool.Shared.Rent(20); + Span buffer = lease.AsSpan(0, 20); + Assert.True(notification.TryCopyKey(buffer, out var bytesWritten)); + Assert.Equal(7, bytesWritten); + Assert.Equal("testkey", Encoding.UTF8.GetString(lease, 0, bytesWritten)); + ArrayPool.Shared.Return(lease); + } + + [Fact] + public void TryCopyKey_FailsWithSmallBuffer() + { + var channel = RedisChannel.Literal("__keyspace@0__:testkey"); + RedisValue value = "set"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Span buffer = stackalloc byte[3]; // too small + Assert.False(notification.TryCopyKey(buffer, out var bytesWritten)); + Assert.Equal(0, bytesWritten); + } + + [Fact] + public void InvalidChannel_ReturnsFalse() + { + var channel = RedisChannel.Literal("regular:channel"); + RedisValue value = "data"; + + Assert.False(KeyNotification.TryParse(in channel, in value, out var notification)); + } + + [Fact] + public void InvalidKeyspaceChannel_MissingDelimiter_ReturnsFalse() + { + var channel = RedisChannel.Literal("__keyspace@0__"); // missing the key part + RedisValue value = "set"; + + Assert.False(KeyNotification.TryParse(in channel, in value, out var notification)); + } + + [Fact] + public void Keyspace_UnknownEventType_ReturnsUnknown() + { + var channel = RedisChannel.Literal("__keyspace@0__:mykey"); + RedisValue value = "unknownevent"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.True(notification.IsKeySpace); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.Unknown, notification.Type); + Assert.Equal("mykey", (string?)notification.GetKey()); + } + + [Fact] + public void Keyevent_UnknownEventType_ReturnsUnknown() + { + var channel = RedisChannel.Literal("__keyevent@0__:unknownevent"); + RedisValue value = "mykey"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.True(notification.IsKeyEvent); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.Unknown, notification.Type); + Assert.Equal("mykey", (string?)notification.GetKey()); + } + + [Fact] + public void Keyspace_WithColonInKey_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyspace@0__:user:session:12345"); + RedisValue value = "del"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.True(notification.IsKeySpace); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.Del, notification.Type); + Assert.Equal("user:session:12345", (string?)notification.GetKey()); + } + + [Fact] + public void Keyevent_Evicted_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyevent@1__:evicted"); + RedisValue value = "cache:old"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.True(notification.IsKeyEvent); + Assert.Equal(1, notification.Database); + Assert.Equal(KeyNotificationType.Evicted, notification.Type); + Assert.Equal("cache:old", (string?)notification.GetKey()); + } + + [Fact] + public void Keyspace_New_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyspace@0__:newkey"); + RedisValue value = "new"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.True(notification.IsKeySpace); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.New, notification.Type); + Assert.Equal("newkey", (string?)notification.GetKey()); + } + + [Fact] + public void Keyevent_XGroupCreate_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyevent@0__:xgroup-create"); + RedisValue value = "mystream"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.True(notification.IsKeyEvent); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.XGroupCreate, notification.Type); + Assert.Equal("mystream", (string?)notification.GetKey()); + } + + [Fact] + public void Keyspace_TypeChanged_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyspace@0__:mykey"); + RedisValue value = "type_changed"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.True(notification.IsKeySpace); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.TypeChanged, notification.Type); + Assert.Equal("mykey", (string?)notification.GetKey()); + } + + [Fact] + public void Keyevent_HighDatabaseNumber_ParsesCorrectly() + { + var channel = RedisChannel.Literal("__keyevent@999__:set"); + RedisValue value = "testkey"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.True(notification.IsKeyEvent); + Assert.Equal(999, notification.Database); + Assert.Equal(KeyNotificationType.Set, notification.Type); + Assert.Equal("testkey", (string?)notification.GetKey()); + } + + [Fact] + public void Keyevent_NonIntegerDatabase_ParsesWellEnough() + { + var channel = RedisChannel.Literal("__keyevent@abc__:set"); + RedisValue value = "testkey"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.True(notification.IsKeyEvent); + Assert.Equal(-1, notification.Database); + Assert.Equal(KeyNotificationType.Set, notification.Type); + Assert.Equal("testkey", (string?)notification.GetKey()); + } + + [Fact] + public void DefaultKeyNotification_HasExpectedProperties() + { + var notification = default(KeyNotification); + + Assert.False(notification.IsKeySpace); + Assert.False(notification.IsKeyEvent); + Assert.Equal(-1, notification.Database); + Assert.Equal(KeyNotificationType.Unknown, notification.Type); + Assert.True(notification.GetKey().IsNull); + Assert.Equal(0, notification.KeyByteCount); + Assert.True(notification.Channel.IsNull); + Assert.True(notification.Value.IsNull); + + // TryCopyKey should return false and write 0 bytes + Span buffer = stackalloc byte[10]; + Assert.False(notification.TryCopyKey(buffer, out var bytesWritten)); + Assert.Equal(0, bytesWritten); + } + + [Theory] + [InlineData(KeyNotificationTypeFastHash.append.Text, KeyNotificationType.Append)] + [InlineData(KeyNotificationTypeFastHash.copy.Text, KeyNotificationType.Copy)] + [InlineData(KeyNotificationTypeFastHash.del.Text, KeyNotificationType.Del)] + [InlineData(KeyNotificationTypeFastHash.expire.Text, KeyNotificationType.Expire)] + [InlineData(KeyNotificationTypeFastHash.hdel.Text, KeyNotificationType.HDel)] + [InlineData(KeyNotificationTypeFastHash.hexpired.Text, KeyNotificationType.HExpired)] + [InlineData(KeyNotificationTypeFastHash.hincrbyfloat.Text, KeyNotificationType.HIncrByFloat)] + [InlineData(KeyNotificationTypeFastHash.hincrby.Text, KeyNotificationType.HIncrBy)] + [InlineData(KeyNotificationTypeFastHash.hpersist.Text, KeyNotificationType.HPersist)] + [InlineData(KeyNotificationTypeFastHash.hset.Text, KeyNotificationType.HSet)] + [InlineData(KeyNotificationTypeFastHash.incrbyfloat.Text, KeyNotificationType.IncrByFloat)] + [InlineData(KeyNotificationTypeFastHash.incrby.Text, KeyNotificationType.IncrBy)] + [InlineData(KeyNotificationTypeFastHash.linsert.Text, KeyNotificationType.LInsert)] + [InlineData(KeyNotificationTypeFastHash.lpop.Text, KeyNotificationType.LPop)] + [InlineData(KeyNotificationTypeFastHash.lpush.Text, KeyNotificationType.LPush)] + [InlineData(KeyNotificationTypeFastHash.lrem.Text, KeyNotificationType.LRem)] + [InlineData(KeyNotificationTypeFastHash.lset.Text, KeyNotificationType.LSet)] + [InlineData(KeyNotificationTypeFastHash.ltrim.Text, KeyNotificationType.LTrim)] + [InlineData(KeyNotificationTypeFastHash.move_from.Text, KeyNotificationType.MoveFrom)] + [InlineData(KeyNotificationTypeFastHash.move_to.Text, KeyNotificationType.MoveTo)] + [InlineData(KeyNotificationTypeFastHash.persist.Text, KeyNotificationType.Persist)] + [InlineData(KeyNotificationTypeFastHash.rename_from.Text, KeyNotificationType.RenameFrom)] + [InlineData(KeyNotificationTypeFastHash.rename_to.Text, KeyNotificationType.RenameTo)] + [InlineData(KeyNotificationTypeFastHash.restore.Text, KeyNotificationType.Restore)] + [InlineData(KeyNotificationTypeFastHash.rpop.Text, KeyNotificationType.RPop)] + [InlineData(KeyNotificationTypeFastHash.rpush.Text, KeyNotificationType.RPush)] + [InlineData(KeyNotificationTypeFastHash.sadd.Text, KeyNotificationType.SAdd)] + [InlineData(KeyNotificationTypeFastHash.set.Text, KeyNotificationType.Set)] + [InlineData(KeyNotificationTypeFastHash.setrange.Text, KeyNotificationType.SetRange)] + [InlineData(KeyNotificationTypeFastHash.sortstore.Text, KeyNotificationType.SortStore)] + [InlineData(KeyNotificationTypeFastHash.srem.Text, KeyNotificationType.SRem)] + [InlineData(KeyNotificationTypeFastHash.spop.Text, KeyNotificationType.SPop)] + [InlineData(KeyNotificationTypeFastHash.xadd.Text, KeyNotificationType.XAdd)] + [InlineData(KeyNotificationTypeFastHash.xdel.Text, KeyNotificationType.XDel)] + [InlineData(KeyNotificationTypeFastHash.xgroupcreateconsumer.Text, KeyNotificationType.XGroupCreateConsumer)] + [InlineData(KeyNotificationTypeFastHash.xgroupcreate.Text, KeyNotificationType.XGroupCreate)] + [InlineData(KeyNotificationTypeFastHash.xgroupdelconsumer.Text, KeyNotificationType.XGroupDelConsumer)] + [InlineData(KeyNotificationTypeFastHash.xgroupdestroy.Text, KeyNotificationType.XGroupDestroy)] + [InlineData(KeyNotificationTypeFastHash.xgroupsetid.Text, KeyNotificationType.XGroupSetId)] + [InlineData(KeyNotificationTypeFastHash.xsetid.Text, KeyNotificationType.XSetId)] + [InlineData(KeyNotificationTypeFastHash.xtrim.Text, KeyNotificationType.XTrim)] + [InlineData(KeyNotificationTypeFastHash.zadd.Text, KeyNotificationType.ZAdd)] + [InlineData(KeyNotificationTypeFastHash.zdiffstore.Text, KeyNotificationType.ZDiffStore)] + [InlineData(KeyNotificationTypeFastHash.zinterstore.Text, KeyNotificationType.ZInterStore)] + [InlineData(KeyNotificationTypeFastHash.zunionstore.Text, KeyNotificationType.ZUnionStore)] + [InlineData(KeyNotificationTypeFastHash.zincr.Text, KeyNotificationType.ZIncr)] + [InlineData(KeyNotificationTypeFastHash.zrembyrank.Text, KeyNotificationType.ZRemByRank)] + [InlineData(KeyNotificationTypeFastHash.zrembyscore.Text, KeyNotificationType.ZRemByScore)] + [InlineData(KeyNotificationTypeFastHash.zrem.Text, KeyNotificationType.ZRem)] + [InlineData(KeyNotificationTypeFastHash.expired.Text, KeyNotificationType.Expired)] + [InlineData(KeyNotificationTypeFastHash.evicted.Text, KeyNotificationType.Evicted)] + [InlineData(KeyNotificationTypeFastHash._new.Text, KeyNotificationType.New)] + [InlineData(KeyNotificationTypeFastHash.overwritten.Text, KeyNotificationType.Overwritten)] + [InlineData(KeyNotificationTypeFastHash.type_changed.Text, KeyNotificationType.TypeChanged)] + public void FastHashParse_AllKnownValues_ParseCorrectly(string input, KeyNotificationType expected) + { + var result = KeyNotificationTypeFastHash.Parse(Encoding.UTF8.GetBytes(input)); + Assert.Equal(expected, result); + } +} From ed091f3d42f647102c04456b93478ec4390bc9aa Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 22 Jan 2026 14:47:41 +0000 Subject: [PATCH 02/37] clarifications --- .../KeyNotificationTypeFastHash.cs | 41 ++++++++++--------- .../KeyNotificationTests.cs | 24 +++++++---- 2 files changed, 39 insertions(+), 26 deletions(-) diff --git a/src/StackExchange.Redis/KeyNotificationTypeFastHash.cs b/src/StackExchange.Redis/KeyNotificationTypeFastHash.cs index bae3f4944..589b607dd 100644 --- a/src/StackExchange.Redis/KeyNotificationTypeFastHash.cs +++ b/src/StackExchange.Redis/KeyNotificationTypeFastHash.cs @@ -2,6 +2,9 @@ namespace StackExchange.Redis; +/// +/// Internal helper type for fast parsing of key notification types, using [FastHash]. +/// internal static partial class KeyNotificationTypeFastHash { // these are checked by KeyNotificationTypeFastHash_MinMaxBytes_ReflectsActualLengths @@ -46,11 +49,11 @@ srem.Hash when srem.Is(hash, value) => KeyNotificationType.SRem, spop.Hash when spop.Is(hash, value) => KeyNotificationType.SPop, xadd.Hash when xadd.Is(hash, value) => KeyNotificationType.XAdd, xdel.Hash when xdel.Is(hash, value) => KeyNotificationType.XDel, - xgroupcreateconsumer.Hash when xgroupcreateconsumer.Is(hash, value) => KeyNotificationType.XGroupCreateConsumer, - xgroupcreate.Hash when xgroupcreate.Is(hash, value) => KeyNotificationType.XGroupCreate, - xgroupdelconsumer.Hash when xgroupdelconsumer.Is(hash, value) => KeyNotificationType.XGroupDelConsumer, - xgroupdestroy.Hash when xgroupdestroy.Is(hash, value) => KeyNotificationType.XGroupDestroy, - xgroupsetid.Hash when xgroupsetid.Is(hash, value) => KeyNotificationType.XGroupSetId, + xgroup_createconsumer.Hash when xgroup_createconsumer.Is(hash, value) => KeyNotificationType.XGroupCreateConsumer, + xgroup_create.Hash when xgroup_create.Is(hash, value) => KeyNotificationType.XGroupCreate, + xgroup_delconsumer.Hash when xgroup_delconsumer.Is(hash, value) => KeyNotificationType.XGroupDelConsumer, + xgroup_destroy.Hash when xgroup_destroy.Is(hash, value) => KeyNotificationType.XGroupDestroy, + xgroup_setid.Hash when xgroup_setid.Is(hash, value) => KeyNotificationType.XGroupSetId, xsetid.Hash when xsetid.Is(hash, value) => KeyNotificationType.XSetId, xtrim.Hash when xtrim.Is(hash, value) => KeyNotificationType.XTrim, zadd.Hash when zadd.Is(hash, value) => KeyNotificationType.ZAdd, @@ -161,12 +164,12 @@ internal static partial class ltrim { } - [FastHash("move_from")] + [FastHash("move_from")] // by default, the generator interprets underscore as hyphen internal static partial class move_from { } - [FastHash("move_to")] + [FastHash("move_to")] // by default, the generator interprets underscore as hyphen internal static partial class move_to { } @@ -176,12 +179,12 @@ internal static partial class persist { } - [FastHash("rename_from")] + [FastHash("rename_from")] // by default, the generator interprets underscore as hyphen internal static partial class rename_from { } - [FastHash("rename_to")] + [FastHash("rename_to")] // by default, the generator interprets underscore as hyphen internal static partial class rename_to { } @@ -241,28 +244,28 @@ internal static partial class xdel { } - [FastHash("xgroup-createconsumer")] - internal static partial class xgroupcreateconsumer + [FastHash] // note: becomes hyphenated + internal static partial class xgroup_createconsumer { } - [FastHash("xgroup-create")] - internal static partial class xgroupcreate + [FastHash] // note: becomes hyphenated + internal static partial class xgroup_create { } - [FastHash("xgroup-delconsumer")] - internal static partial class xgroupdelconsumer + [FastHash] // note: becomes hyphenated + internal static partial class xgroup_delconsumer { } - [FastHash("xgroup-destroy")] - internal static partial class xgroupdestroy + [FastHash] // note: becomes hyphenated + internal static partial class xgroup_destroy { } - [FastHash("xgroup-setid")] - internal static partial class xgroupsetid + [FastHash] // note: becomes hyphenated + internal static partial class xgroup_setid { } diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs index 6b0a32bbf..fec809695 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs @@ -353,11 +353,11 @@ public void DefaultKeyNotification_HasExpectedProperties() [InlineData(KeyNotificationTypeFastHash.spop.Text, KeyNotificationType.SPop)] [InlineData(KeyNotificationTypeFastHash.xadd.Text, KeyNotificationType.XAdd)] [InlineData(KeyNotificationTypeFastHash.xdel.Text, KeyNotificationType.XDel)] - [InlineData(KeyNotificationTypeFastHash.xgroupcreateconsumer.Text, KeyNotificationType.XGroupCreateConsumer)] - [InlineData(KeyNotificationTypeFastHash.xgroupcreate.Text, KeyNotificationType.XGroupCreate)] - [InlineData(KeyNotificationTypeFastHash.xgroupdelconsumer.Text, KeyNotificationType.XGroupDelConsumer)] - [InlineData(KeyNotificationTypeFastHash.xgroupdestroy.Text, KeyNotificationType.XGroupDestroy)] - [InlineData(KeyNotificationTypeFastHash.xgroupsetid.Text, KeyNotificationType.XGroupSetId)] + [InlineData(KeyNotificationTypeFastHash.xgroup_createconsumer.Text, KeyNotificationType.XGroupCreateConsumer)] + [InlineData(KeyNotificationTypeFastHash.xgroup_create.Text, KeyNotificationType.XGroupCreate)] + [InlineData(KeyNotificationTypeFastHash.xgroup_delconsumer.Text, KeyNotificationType.XGroupDelConsumer)] + [InlineData(KeyNotificationTypeFastHash.xgroup_destroy.Text, KeyNotificationType.XGroupDestroy)] + [InlineData(KeyNotificationTypeFastHash.xgroup_setid.Text, KeyNotificationType.XGroupSetId)] [InlineData(KeyNotificationTypeFastHash.xsetid.Text, KeyNotificationType.XSetId)] [InlineData(KeyNotificationTypeFastHash.xtrim.Text, KeyNotificationType.XTrim)] [InlineData(KeyNotificationTypeFastHash.zadd.Text, KeyNotificationType.ZAdd)] @@ -373,9 +373,19 @@ public void DefaultKeyNotification_HasExpectedProperties() [InlineData(KeyNotificationTypeFastHash._new.Text, KeyNotificationType.New)] [InlineData(KeyNotificationTypeFastHash.overwritten.Text, KeyNotificationType.Overwritten)] [InlineData(KeyNotificationTypeFastHash.type_changed.Text, KeyNotificationType.TypeChanged)] - public void FastHashParse_AllKnownValues_ParseCorrectly(string input, KeyNotificationType expected) + public unsafe void FastHashParse_AllKnownValues_ParseCorrectly(string input, KeyNotificationType expected) { - var result = KeyNotificationTypeFastHash.Parse(Encoding.UTF8.GetBytes(input)); + var arr = ArrayPool.Shared.Rent(Encoding.UTF8.GetMaxByteCount(input.Length)); + int bytes; + fixed (byte* bPtr = arr) // encode into the buffer + { + fixed (char* cPtr = input) + { + bytes = Encoding.UTF8.GetBytes(cPtr, input.Length, bPtr, arr.Length); + } + } + var result = KeyNotificationTypeFastHash.Parse(arr.AsSpan(0, bytes)); + ArrayPool.Shared.Return(arr); Assert.Equal(expected, result); } } From 2737854b665efdfea8b466ea45b5592aa6db4618 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 22 Jan 2026 16:59:21 +0000 Subject: [PATCH 03/37] RedisChannel creation API --- .../KeyNotificationType.cs | 96 ++++++++--------- .../KeyNotificationTypeFastHash.cs | 63 +++++++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 99 ++++++++--------- src/StackExchange.Redis/RedisChannel.cs | 102 +++++++++++++++++- .../KeyNotificationTests.cs | 68 ++++++++++-- 5 files changed, 325 insertions(+), 103 deletions(-) diff --git a/src/StackExchange.Redis/KeyNotificationType.cs b/src/StackExchange.Redis/KeyNotificationType.cs index 159a518a4..cc4c74ef1 100644 --- a/src/StackExchange.Redis/KeyNotificationType.cs +++ b/src/StackExchange.Redis/KeyNotificationType.cs @@ -10,54 +10,54 @@ public enum KeyNotificationType #pragma warning disable CS1591 // docs, redundant Unknown = 0, Append = 1, - Copy = 1, - Del = 2, - Expire = 3, - HDel = 4, - HExpired = 5, - HIncrByFloat = 6, - HIncrBy = 7, - HPersist = 8, - HSet = 9, - IncrByFloat = 10, - IncrBy = 11, - LInsert = 12, - LPop = 13, - LPush = 14, - LRem = 15, - LSet = 16, - LTrim = 17, - MoveFrom = 18, - MoveTo = 19, - Persist = 20, - RenameFrom = 21, - RenameTo = 22, - Restore = 23, - RPop = 24, - RPush = 25, - SAdd = 26, - Set = 27, - SetRange = 28, - SortStore = 29, - SRem = 30, - SPop = 31, - XAdd = 32, - XDel = 33, - XGroupCreateConsumer = 34, - XGroupCreate = 35, - XGroupDelConsumer = 36, - XGroupDestroy = 37, - XGroupSetId = 38, - XSetId = 39, - XTrim = 40, - ZAdd = 41, - ZDiffStore = 42, - ZInterStore = 43, - ZUnionStore = 44, - ZIncr = 45, - ZRemByRank = 46, - ZRemByScore = 47, - ZRem = 48, + Copy = 2, + Del = 3, + Expire = 4, + HDel = 5, + HExpired = 6, + HIncrByFloat = 7, + HIncrBy = 8, + HPersist = 9, + HSet = 10, + IncrByFloat = 11, + IncrBy = 12, + LInsert = 13, + LPop = 14, + LPush = 15, + LRem = 16, + LSet = 17, + LTrim = 18, + MoveFrom = 19, + MoveTo = 20, + Persist = 21, + RenameFrom = 22, + RenameTo = 23, + Restore = 24, + RPop = 25, + RPush = 26, + SAdd = 27, + Set = 28, + SetRange = 29, + SortStore = 30, + SRem = 31, + SPop = 32, + XAdd = 33, + XDel = 34, + XGroupCreateConsumer = 35, + XGroupCreate = 36, + XGroupDelConsumer = 37, + XGroupDestroy = 38, + XGroupSetId = 39, + XSetId = 40, + XTrim = 41, + ZAdd = 42, + ZDiffStore = 43, + ZInterStore = 44, + ZUnionStore = 45, + ZIncr = 46, + ZRemByRank = 47, + ZRemByScore = 48, + ZRem = 49, // side-effect notifications Expired = 1000, diff --git a/src/StackExchange.Redis/KeyNotificationTypeFastHash.cs b/src/StackExchange.Redis/KeyNotificationTypeFastHash.cs index 589b607dd..e67de0f18 100644 --- a/src/StackExchange.Redis/KeyNotificationTypeFastHash.cs +++ b/src/StackExchange.Redis/KeyNotificationTypeFastHash.cs @@ -346,4 +346,67 @@ internal static partial class type_changed // ReSharper restore InconsistentNaming #pragma warning restore SA1300, CS8981 + + internal static ReadOnlySpan GetRawBytes(KeyNotificationType type) + { + return type switch + { + KeyNotificationType.Append => append.U8, + KeyNotificationType.Copy => copy.U8, + KeyNotificationType.Del => del.U8, + KeyNotificationType.Expire => expire.U8, + KeyNotificationType.HDel => hdel.U8, + KeyNotificationType.HExpired => hexpired.U8, + KeyNotificationType.HIncrByFloat => hincrbyfloat.U8, + KeyNotificationType.HIncrBy => hincrby.U8, + KeyNotificationType.HPersist => hpersist.U8, + KeyNotificationType.HSet => hset.U8, + KeyNotificationType.IncrByFloat => incrbyfloat.U8, + KeyNotificationType.IncrBy => incrby.U8, + KeyNotificationType.LInsert => linsert.U8, + KeyNotificationType.LPop => lpop.U8, + KeyNotificationType.LPush => lpush.U8, + KeyNotificationType.LRem => lrem.U8, + KeyNotificationType.LSet => lset.U8, + KeyNotificationType.LTrim => ltrim.U8, + KeyNotificationType.MoveFrom => move_from.U8, + KeyNotificationType.MoveTo => move_to.U8, + KeyNotificationType.Persist => persist.U8, + KeyNotificationType.RenameFrom => rename_from.U8, + KeyNotificationType.RenameTo => rename_to.U8, + KeyNotificationType.Restore => restore.U8, + KeyNotificationType.RPop => rpop.U8, + KeyNotificationType.RPush => rpush.U8, + KeyNotificationType.SAdd => sadd.U8, + KeyNotificationType.Set => set.U8, + KeyNotificationType.SetRange => setrange.U8, + KeyNotificationType.SortStore => sortstore.U8, + KeyNotificationType.SRem => srem.U8, + KeyNotificationType.SPop => spop.U8, + KeyNotificationType.XAdd => xadd.U8, + KeyNotificationType.XDel => xdel.U8, + KeyNotificationType.XGroupCreateConsumer => xgroup_createconsumer.U8, + KeyNotificationType.XGroupCreate => xgroup_create.U8, + KeyNotificationType.XGroupDelConsumer => xgroup_delconsumer.U8, + KeyNotificationType.XGroupDestroy => xgroup_destroy.U8, + KeyNotificationType.XGroupSetId => xgroup_setid.U8, + KeyNotificationType.XSetId => xsetid.U8, + KeyNotificationType.XTrim => xtrim.U8, + KeyNotificationType.ZAdd => zadd.U8, + KeyNotificationType.ZDiffStore => zdiffstore.U8, + KeyNotificationType.ZInterStore => zinterstore.U8, + KeyNotificationType.ZUnionStore => zunionstore.U8, + KeyNotificationType.ZIncr => zincr.U8, + KeyNotificationType.ZRemByRank => zrembyrank.U8, + KeyNotificationType.ZRemByScore => zrembyscore.U8, + KeyNotificationType.ZRem => zrem.U8, + KeyNotificationType.Expired => expired.U8, + KeyNotificationType.Evicted => evicted.U8, + KeyNotificationType.New => _new.U8, + KeyNotificationType.Overwritten => overwritten.U8, + KeyNotificationType.TypeChanged => type_changed.U8, + _ => Throw(), + }; + static ReadOnlySpan Throw() => throw new ArgumentOutOfRangeException(nameof(type)); + } } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 4a767fe9b..1e476cbb5 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -12,59 +12,62 @@ StackExchange.Redis.KeyNotification.TryCopyKey(System.Span destination, ou StackExchange.Redis.KeyNotification.Type.get -> StackExchange.Redis.KeyNotificationType static StackExchange.Redis.KeyNotification.TryParse(in StackExchange.Redis.RedisChannel channel, in StackExchange.Redis.RedisValue value, out StackExchange.Redis.KeyNotification notification) -> bool StackExchange.Redis.ChannelMessage.TryParseKeyNotification(out StackExchange.Redis.KeyNotification notification) -> bool +static StackExchange.Redis.RedisChannel.KeyEvent(StackExchange.Redis.KeyNotificationType type, int? database = null) -> StackExchange.Redis.RedisChannel +static StackExchange.Redis.RedisChannel.KeySpace(in StackExchange.Redis.RedisKey key, int database) -> StackExchange.Redis.RedisChannel +static StackExchange.Redis.RedisChannel.KeySpacePattern(in StackExchange.Redis.RedisKey pattern, int? database = null) -> StackExchange.Redis.RedisChannel StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.Append = 1 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.Copy = 1 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.Del = 2 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.Copy = 2 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.Del = 3 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.Evicted = 1001 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.Expire = 3 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.Expire = 4 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.Expired = 1000 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.HDel = 4 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.HExpired = 5 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.HIncrBy = 7 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.HIncrByFloat = 6 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.HPersist = 8 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.HSet = 9 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.IncrBy = 11 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.IncrByFloat = 10 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.LInsert = 12 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.LPop = 13 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.LPush = 14 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.LRem = 15 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.LSet = 16 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.LTrim = 17 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.MoveFrom = 18 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.MoveTo = 19 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.HDel = 5 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.HExpired = 6 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.HIncrBy = 8 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.HIncrByFloat = 7 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.HPersist = 9 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.HSet = 10 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.IncrBy = 12 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.IncrByFloat = 11 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.LInsert = 13 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.LPop = 14 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.LPush = 15 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.LRem = 16 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.LSet = 17 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.LTrim = 18 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.MoveFrom = 19 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.MoveTo = 20 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.New = 1002 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.Overwritten = 1003 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.Persist = 20 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.RenameFrom = 21 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.RenameTo = 22 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.Restore = 23 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.RPop = 24 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.RPush = 25 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.SAdd = 26 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.Set = 27 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.SetRange = 28 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.SortStore = 29 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.SPop = 31 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.SRem = 30 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.Persist = 21 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.RenameFrom = 22 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.RenameTo = 23 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.Restore = 24 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.RPop = 25 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.RPush = 26 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.SAdd = 27 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.Set = 28 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.SetRange = 29 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.SortStore = 30 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.SPop = 32 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.SRem = 31 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.TypeChanged = 1004 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.Unknown = 0 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.XAdd = 32 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.XDel = 33 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.XGroupCreate = 35 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.XGroupCreateConsumer = 34 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.XGroupDelConsumer = 36 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.XGroupDestroy = 37 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.XGroupSetId = 38 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.XSetId = 39 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.XTrim = 40 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.ZAdd = 41 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.ZDiffStore = 42 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.ZIncr = 45 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.ZInterStore = 43 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.ZRem = 48 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.ZRemByRank = 46 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.ZRemByScore = 47 -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.KeyNotificationType.ZUnionStore = 44 -> StackExchange.Redis.KeyNotificationType \ No newline at end of file +StackExchange.Redis.KeyNotificationType.XAdd = 33 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.XDel = 34 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.XGroupCreate = 36 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.XGroupCreateConsumer = 35 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.XGroupDelConsumer = 37 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.XGroupDestroy = 38 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.XGroupSetId = 39 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.XSetId = 40 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.XTrim = 41 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ZAdd = 42 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ZDiffStore = 43 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ZIncr = 46 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ZInterStore = 44 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ZRem = 49 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ZRemByRank = 47 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ZRemByScore = 48 -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.KeyNotificationType.ZUnionStore = 45 -> StackExchange.Redis.KeyNotificationType diff --git a/src/StackExchange.Redis/RedisChannel.cs b/src/StackExchange.Redis/RedisChannel.cs index 8e8373022..54e761322 100644 --- a/src/StackExchange.Redis/RedisChannel.cs +++ b/src/StackExchange.Redis/RedisChannel.cs @@ -1,4 +1,7 @@ using System; +using System.Buffers.Text; +using System.Diagnostics; +using System.Runtime.CompilerServices; using System.Text; namespace StackExchange.Redis @@ -21,10 +24,11 @@ internal enum RedisChannelOptions Pattern = 1 << 0, Sharded = 1 << 1, KeyRouted = 1 << 2, + MultiNode = 1 << 3, } // we don't consider Routed for equality - it's an implementation detail, not a fundamental feature - private const RedisChannelOptions EqualityMask = ~RedisChannelOptions.KeyRouted; + private const RedisChannelOptions EqualityMask = ~(RedisChannelOptions.KeyRouted | RedisChannelOptions.MultiNode); internal RedisCommand PublishCommand => IsSharded ? RedisCommand.SPUBLISH : RedisCommand.PUBLISH; @@ -34,6 +38,8 @@ internal enum RedisChannelOptions /// internal bool IsKeyRouted => (Options & RedisChannelOptions.KeyRouted) != 0; + internal bool IsMultiNode => (Options & RedisChannelOptions.MultiNode) != 0; + /// /// Indicates whether the channel-name is either null or a zero-length value. /// @@ -143,6 +149,100 @@ public RedisChannel(string value, PatternMode mode) : this(value is null ? null /// using sharded channels must also be published with sharded channels (and vice versa). public static RedisChannel Sharded(string value) => new(value, RedisChannelOptions.Sharded | RedisChannelOptions.KeyRouted); + /// + /// Create a key-notification channel for a single key in a single database. + /// + public static RedisChannel KeySpace(in RedisKey key, int database) + => BuildKeySpace(key, database, RedisChannelOptions.None); + + /// + /// Create a key-notification channel for a pattern, optionally in a specified database. + /// + public static RedisChannel KeySpacePattern(in RedisKey pattern, int? database = null) + => BuildKeySpace(pattern, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode); + + private const int DatabaseScratchBufferSize = 16; // largest non-negative int32 is 10 digits + + private static ReadOnlySpan AppendDatabase(Span target, int? database, RedisChannelOptions options) + { + if (database is null) + { + if ((options & RedisChannelOptions.Pattern) == 0) throw new ArgumentNullException(nameof(database)); + target[0] = (byte)'*'; + return target.Slice(0, 1); + } + else + { + var db32 = database.GetValueOrDefault(); + if (db32 < 0) throw new ArgumentOutOfRangeException(nameof(database)); + return target.Slice(0, Format.FormatInt32(db32, target)); + } + } + + /// + /// Create a key-notification channel for a pattern, optionally in a specified database. + /// + public static RedisChannel KeyEvent(KeyNotificationType type, int? database = null) + { + RedisChannelOptions options = RedisChannelOptions.MultiNode; + if (database is null) options |= RedisChannelOptions.Pattern; + var db = AppendDatabase(stackalloc byte[DatabaseScratchBufferSize], database, options); + var typeBytes = KeyNotificationTypeFastHash.GetRawBytes(type); + + // __keyevent@{db}__:{type} + var arr = new byte[14 + db.Length + typeBytes.Length]; + + Span target = AppendAndAdvance(arr.AsSpan(), "__keyevent@"u8); + target = AppendAndAdvance(target, db); + target = AppendAndAdvance(target, "__:"u8); + target = AppendAndAdvance(target, typeBytes); + Debug.Assert(target.IsEmpty); // should have calculated length correctly + + return new RedisChannel(arr, options); + } + + private static Span AppendAndAdvance(Span target, scoped ReadOnlySpan value) + { + value.CopyTo(target); + return target.Slice(value.Length); + } + + private static RedisChannel BuildKeySpace(in RedisKey key, int? database, RedisChannelOptions options) + { + int keyLen; + if (key.IsNull) + { + if ((options & RedisChannelOptions.Pattern) == 0) throw new ArgumentNullException(nameof(key)); + keyLen = 1; + } + else + { + keyLen = key.TotalLength(); + if (keyLen == 0) throw new ArgumentOutOfRangeException(nameof(key)); + } + + var db = AppendDatabase(stackalloc byte[DatabaseScratchBufferSize], database, options); + + // __keyspace@{db}__:{key} + var arr = new byte[14 + db.Length + keyLen]; + + Span target = AppendAndAdvance(arr.AsSpan(), "__keyspace@"u8); + target = AppendAndAdvance(target, db); + target = AppendAndAdvance(target, "__:"u8); + Debug.Assert(keyLen == target.Length); // should have exactly "len" bytes remaining + if (key.IsNull) + { + target[0] = (byte)'*'; + target = target.Slice(1); + } + else + { + target = target.Slice(key.CopyTo(target)); + } + Debug.Assert(target.IsEmpty); // should have calculated length correctly + return new RedisChannel(arr, options); + } + internal RedisChannel(byte[]? value, RedisChannelOptions options) { Value = value; diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs index fec809695..607ed4871 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs @@ -5,7 +5,7 @@ namespace StackExchange.Redis.Tests; -public class KeyNotificationTests +public class KeyNotificationTests(ITestOutputHelper log) { [Fact] public void Keyspace_Del_ParsesCorrectly() @@ -373,19 +373,75 @@ public void DefaultKeyNotification_HasExpectedProperties() [InlineData(KeyNotificationTypeFastHash._new.Text, KeyNotificationType.New)] [InlineData(KeyNotificationTypeFastHash.overwritten.Text, KeyNotificationType.Overwritten)] [InlineData(KeyNotificationTypeFastHash.type_changed.Text, KeyNotificationType.TypeChanged)] - public unsafe void FastHashParse_AllKnownValues_ParseCorrectly(string input, KeyNotificationType expected) + public unsafe void FastHashParse_AllKnownValues_ParseCorrectly(string raw, KeyNotificationType parsed) { - var arr = ArrayPool.Shared.Rent(Encoding.UTF8.GetMaxByteCount(input.Length)); + var arr = ArrayPool.Shared.Rent(Encoding.UTF8.GetMaxByteCount(raw.Length)); int bytes; fixed (byte* bPtr = arr) // encode into the buffer { - fixed (char* cPtr = input) + fixed (char* cPtr = raw) { - bytes = Encoding.UTF8.GetBytes(cPtr, input.Length, bPtr, arr.Length); + bytes = Encoding.UTF8.GetBytes(cPtr, raw.Length, bPtr, arr.Length); } } + var result = KeyNotificationTypeFastHash.Parse(arr.AsSpan(0, bytes)); + log.WriteLine($"Parsed '{raw}' as {result}"); + Assert.Equal(parsed, result); + + // and the other direction: + var fetchedBytes = KeyNotificationTypeFastHash.GetRawBytes(parsed); + string fetched; + fixed (byte* bPtr = fetchedBytes) + { + fetched = Encoding.UTF8.GetString(bPtr, fetchedBytes.Length); + } + + log.WriteLine($"Fetched '{raw}'"); + Assert.Equal(raw, fetched); + ArrayPool.Shared.Return(arr); - Assert.Equal(expected, result); + } + + [Fact] + public void CreateKeySpaceNotification_Valid() + { + var channel = RedisChannel.KeySpace("abc", 42); + Assert.Equal("__keyspace@42__:abc", channel.ToString()); + Assert.False(channel.IsMultiNode); + Assert.False(channel.IsPattern); + } + + [Theory] + [InlineData(null, null, "__keyspace@*__:*")] + [InlineData("abc*", null, "__keyspace@*__:abc*")] + [InlineData(null, 42, "__keyspace@42__:*")] + [InlineData("abc*", 42, "__keyspace@42__:abc*")] + public void CreateKeySpaceNotificationPattern(string? pattern, int? database, string expected) + { + var channel = RedisChannel.KeySpacePattern(pattern, database); + Assert.Equal(expected, channel.ToString()); + Assert.True(channel.IsMultiNode); + Assert.True(channel.IsPattern); + } + + [Theory] + [InlineData(KeyNotificationType.Set, null, "__keyevent@*__:set", true)] + [InlineData(KeyNotificationType.XGroupCreate, null, "__keyevent@*__:xgroup-create", true)] + [InlineData(KeyNotificationType.Set, 42, "__keyevent@42__:set", false)] + [InlineData(KeyNotificationType.XGroupCreate, 42, "__keyevent@42__:xgroup-create", false)] + public void CreateKeyEventNotification(KeyNotificationType type, int? database, string expected, bool isPattern) + { + var channel = RedisChannel.KeyEvent(type, database); + Assert.Equal(expected, channel.ToString()); + Assert.True(channel.IsMultiNode); + if (isPattern) + { + Assert.True(channel.IsPattern); + } + else + { + Assert.False(channel.IsPattern); + } } } From 1c76f80d48f04cf9a72fd6636d44547b9126100c Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 22 Jan 2026 17:01:37 +0000 Subject: [PATCH 04/37] assert non-sharded in tests --- tests/StackExchange.Redis.Tests/KeyNotificationTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs index 607ed4871..77c253cb3 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs @@ -409,6 +409,7 @@ public void CreateKeySpaceNotification_Valid() var channel = RedisChannel.KeySpace("abc", 42); Assert.Equal("__keyspace@42__:abc", channel.ToString()); Assert.False(channel.IsMultiNode); + Assert.False(channel.IsSharded); Assert.False(channel.IsPattern); } @@ -422,6 +423,7 @@ public void CreateKeySpaceNotificationPattern(string? pattern, int? database, st var channel = RedisChannel.KeySpacePattern(pattern, database); Assert.Equal(expected, channel.ToString()); Assert.True(channel.IsMultiNode); + Assert.False(channel.IsSharded); Assert.True(channel.IsPattern); } @@ -435,6 +437,7 @@ public void CreateKeyEventNotification(KeyNotificationType type, int? database, var channel = RedisChannel.KeyEvent(type, database); Assert.Equal(expected, channel.ToString()); Assert.True(channel.IsMultiNode); + Assert.False(channel.IsSharded); if (isPattern) { Assert.True(channel.IsPattern); From 0292eaea21dc9e8b0b2a561a73ce6d9fa5aea9d7 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 22 Jan 2026 17:04:32 +0000 Subject: [PATCH 05/37] simplify database handling for null and zero --- src/StackExchange.Redis/RedisChannel.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/StackExchange.Redis/RedisChannel.cs b/src/StackExchange.Redis/RedisChannel.cs index 54e761322..e1b338e7d 100644 --- a/src/StackExchange.Redis/RedisChannel.cs +++ b/src/StackExchange.Redis/RedisChannel.cs @@ -168,12 +168,12 @@ private static ReadOnlySpan AppendDatabase(Span target, int? databas if (database is null) { if ((options & RedisChannelOptions.Pattern) == 0) throw new ArgumentNullException(nameof(database)); - target[0] = (byte)'*'; - return target.Slice(0, 1); + return "*"u8; // don't worry about the inbound scratch buffer, this is fine } else { var db32 = database.GetValueOrDefault(); + if (db32 == 0) return "0"u8; // so common, we might as well special case if (db32 < 0) throw new ArgumentOutOfRangeException(nameof(database)); return target.Slice(0, Format.FormatInt32(db32, target)); } From ca5b791454072a5c5be7bd63a3b17a4a537cff96 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 22 Jan 2026 17:11:10 +0000 Subject: [PATCH 06/37] Add API for KeyEvent usage with unexpected event types --- .../PublicAPI/PublicAPI.Unshipped.txt | 1 + src/StackExchange.Redis/RedisChannel.cs | 33 ++++++++++++++----- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 1e476cbb5..871fe71f0 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -13,6 +13,7 @@ StackExchange.Redis.KeyNotification.Type.get -> StackExchange.Redis.KeyNotificat static StackExchange.Redis.KeyNotification.TryParse(in StackExchange.Redis.RedisChannel channel, in StackExchange.Redis.RedisValue value, out StackExchange.Redis.KeyNotification notification) -> bool StackExchange.Redis.ChannelMessage.TryParseKeyNotification(out StackExchange.Redis.KeyNotification notification) -> bool static StackExchange.Redis.RedisChannel.KeyEvent(StackExchange.Redis.KeyNotificationType type, int? database = null) -> StackExchange.Redis.RedisChannel +static StackExchange.Redis.RedisChannel.KeyEvent(System.ReadOnlySpan type, int? database) -> StackExchange.Redis.RedisChannel static StackExchange.Redis.RedisChannel.KeySpace(in StackExchange.Redis.RedisKey key, int database) -> StackExchange.Redis.RedisChannel static StackExchange.Redis.RedisChannel.KeySpacePattern(in StackExchange.Redis.RedisKey pattern, int? database = null) -> StackExchange.Redis.RedisChannel StackExchange.Redis.KeyNotificationType diff --git a/src/StackExchange.Redis/RedisChannel.cs b/src/StackExchange.Redis/RedisChannel.cs index e1b338e7d..c25f6a00a 100644 --- a/src/StackExchange.Redis/RedisChannel.cs +++ b/src/StackExchange.Redis/RedisChannel.cs @@ -28,7 +28,8 @@ internal enum RedisChannelOptions } // we don't consider Routed for equality - it's an implementation detail, not a fundamental feature - private const RedisChannelOptions EqualityMask = ~(RedisChannelOptions.KeyRouted | RedisChannelOptions.MultiNode); + private const RedisChannelOptions EqualityMask = + ~(RedisChannelOptions.KeyRouted | RedisChannelOptions.MultiNode); internal RedisCommand PublishCommand => IsSharded ? RedisCommand.SPUBLISH : RedisCommand.PUBLISH; @@ -66,6 +67,7 @@ public static bool UseImplicitAutoPattern get => s_DefaultPatternMode == PatternMode.Auto; set => s_DefaultPatternMode = value ? PatternMode.Auto : PatternMode.Literal; } + private static PatternMode s_DefaultPatternMode = PatternMode.Auto; /// @@ -113,7 +115,8 @@ public static bool UseImplicitAutoPattern /// /// The name of the channel to create. /// The mode for name matching. - public RedisChannel(byte[]? value, PatternMode mode) : this(value, DeterminePatternBased(value, mode) ? RedisChannelOptions.Pattern : RedisChannelOptions.None) + public RedisChannel(byte[]? value, PatternMode mode) : this( + value, DeterminePatternBased(value, mode) ? RedisChannelOptions.Pattern : RedisChannelOptions.None) { } @@ -123,7 +126,9 @@ public RedisChannel(byte[]? value, PatternMode mode) : this(value, DeterminePatt /// The string name of the channel to create. /// The mode for name matching. // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract - public RedisChannel(string value, PatternMode mode) : this(value is null ? null : Encoding.UTF8.GetBytes(value), mode) + public RedisChannel(string value, PatternMode mode) : this( + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + value is null ? null : Encoding.UTF8.GetBytes(value), mode) { } @@ -136,7 +141,8 @@ public RedisChannel(string value, PatternMode mode) : this(value is null ? null /// The name of the channel to create. /// Note that sharded subscriptions are completely separate to regular subscriptions; subscriptions /// using sharded channels must also be published with sharded channels (and vice versa). - public static RedisChannel Sharded(byte[]? value) => new(value, RedisChannelOptions.Sharded | RedisChannelOptions.KeyRouted); + public static RedisChannel Sharded(byte[]? value) => + new(value, RedisChannelOptions.Sharded | RedisChannelOptions.KeyRouted); /// /// Create a new redis channel from a string, representing a sharded channel. In cluster @@ -147,7 +153,8 @@ public RedisChannel(string value, PatternMode mode) : this(value is null ? null /// The string name of the channel to create. /// Note that sharded subscriptions are completely separate to regular subscriptions; subscriptions /// using sharded channels must also be published with sharded channels (and vice versa). - public static RedisChannel Sharded(string value) => new(value, RedisChannelOptions.Sharded | RedisChannelOptions.KeyRouted); + public static RedisChannel Sharded(string value) => + new(value, RedisChannelOptions.Sharded | RedisChannelOptions.KeyRouted); /// /// Create a key-notification channel for a single key in a single database. @@ -182,20 +189,30 @@ private static ReadOnlySpan AppendDatabase(Span target, int? databas /// /// Create a key-notification channel for a pattern, optionally in a specified database. /// +#pragma warning disable RS0027 public static RedisChannel KeyEvent(KeyNotificationType type, int? database = null) +#pragma warning restore RS0027 + => KeyEvent(KeyNotificationTypeFastHash.GetRawBytes(type), database); + + /// + /// Create a key-notification channel for a pattern, optionally in a specified database. + /// + /// This API is intended for use with custom/unknown event types; for well-known types, use . + public static RedisChannel KeyEvent(ReadOnlySpan type, int? database) { + if (type.IsEmpty) throw new ArgumentNullException(nameof(type)); + RedisChannelOptions options = RedisChannelOptions.MultiNode; if (database is null) options |= RedisChannelOptions.Pattern; var db = AppendDatabase(stackalloc byte[DatabaseScratchBufferSize], database, options); - var typeBytes = KeyNotificationTypeFastHash.GetRawBytes(type); // __keyevent@{db}__:{type} - var arr = new byte[14 + db.Length + typeBytes.Length]; + var arr = new byte[14 + db.Length + type.Length]; Span target = AppendAndAdvance(arr.AsSpan(), "__keyevent@"u8); target = AppendAndAdvance(target, db); target = AppendAndAdvance(target, "__:"u8); - target = AppendAndAdvance(target, typeBytes); + target = AppendAndAdvance(target, type); Debug.Assert(target.IsEmpty); // should have calculated length correctly return new RedisChannel(arr, options); From 709bc5297b671099a686dc0623e8db68fa1f479c Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 23 Jan 2026 11:39:38 +0000 Subject: [PATCH 07/37] nits --- StackExchange.Redis.sln.DotSettings | 3 +++ src/StackExchange.Redis/RedisChannel.cs | 25 ++++++++++++++++--------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/StackExchange.Redis.sln.DotSettings b/StackExchange.Redis.sln.DotSettings index 216edbcca..8dd9095d9 100644 --- a/StackExchange.Redis.sln.DotSettings +++ b/StackExchange.Redis.sln.DotSettings @@ -12,9 +12,12 @@ True True True + True True True + True True + True True True True diff --git a/src/StackExchange.Redis/RedisChannel.cs b/src/StackExchange.Redis/RedisChannel.cs index c25f6a00a..c2e780778 100644 --- a/src/StackExchange.Redis/RedisChannel.cs +++ b/src/StackExchange.Redis/RedisChannel.cs @@ -1,7 +1,5 @@ using System; -using System.Buffers.Text; using System.Diagnostics; -using System.Runtime.CompilerServices; using System.Text; namespace StackExchange.Redis @@ -34,11 +32,14 @@ internal enum RedisChannelOptions internal RedisCommand PublishCommand => IsSharded ? RedisCommand.SPUBLISH : RedisCommand.PUBLISH; /// - /// Should we use cluster routing for this channel? This applies *either* to sharded (SPUBLISH) scenarios, + /// Should we use cluster routing for this channel? This applies *either* to sharded (SPUBLISH) scenarios, /// or to scenarios using . /// internal bool IsKeyRouted => (Options & RedisChannelOptions.KeyRouted) != 0; + /// + /// Should this channel be subscribed to on all nodes? This is only relevant for cluster scenarios and keyspace notifications. + /// internal bool IsMultiNode => (Options & RedisChannelOptions.MultiNode) != 0; /// @@ -92,7 +93,13 @@ public static bool UseImplicitAutoPattern /// a consideration. /// /// Note that channels from Sharded are always routed. - public RedisChannel WithKeyRouting() => new(Value, Options | RedisChannelOptions.KeyRouted); + public RedisChannel WithKeyRouting() + { + if (IsMultiNode) Throw(); + return new(Value, Options | RedisChannelOptions.KeyRouted); + + static void Throw() => throw new InvalidOperationException("Key routing is not supported for multi-node channels"); + } /// /// Creates a new that acts as a wildcard subscription. In cluster @@ -187,7 +194,7 @@ private static ReadOnlySpan AppendDatabase(Span target, int? databas } /// - /// Create a key-notification channel for a pattern, optionally in a specified database. + /// Create an event-notification channel for a given event type, optionally in a specified database. /// #pragma warning disable RS0027 public static RedisChannel KeyEvent(KeyNotificationType type, int? database = null) @@ -195,7 +202,7 @@ public static RedisChannel KeyEvent(KeyNotificationType type, int? database = nu => KeyEvent(KeyNotificationTypeFastHash.GetRawBytes(type), database); /// - /// Create a key-notification channel for a pattern, optionally in a specified database. + /// Create an event-notification channel for a given event type, optionally in a specified database. /// /// This API is intended for use with custom/unknown event types; for well-known types, use . public static RedisChannel KeyEvent(ReadOnlySpan type, int? database) @@ -209,7 +216,7 @@ public static RedisChannel KeyEvent(ReadOnlySpan type, int? database) // __keyevent@{db}__:{type} var arr = new byte[14 + db.Length + type.Length]; - Span target = AppendAndAdvance(arr.AsSpan(), "__keyevent@"u8); + var target = AppendAndAdvance(arr.AsSpan(), "__keyevent@"u8); target = AppendAndAdvance(target, db); target = AppendAndAdvance(target, "__:"u8); target = AppendAndAdvance(target, type); @@ -243,7 +250,7 @@ private static RedisChannel BuildKeySpace(in RedisKey key, int? database, RedisC // __keyspace@{db}__:{key} var arr = new byte[14 + db.Length + keyLen]; - Span target = AppendAndAdvance(arr.AsSpan(), "__keyspace@"u8); + var target = AppendAndAdvance(arr.AsSpan(), "__keyspace@"u8); target = AppendAndAdvance(target, db); target = AppendAndAdvance(target, "__:"u8); Debug.Assert(keyLen == target.Length); // should have exactly "len" bytes remaining @@ -470,7 +477,7 @@ public static implicit operator RedisChannel(byte[]? key) { return Encoding.UTF8.GetString(arr); } - catch (Exception e) when // Only catch exception throwed by Encoding.UTF8.GetString + catch (Exception e) when // Only catch exception thrown by Encoding.UTF8.GetString (e is DecoderFallbackException or ArgumentException or ArgumentNullException) { return BitConverter.ToString(arr); From f4c0277e79e8f7528f618a0dc16eb24a0e885a95 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 23 Jan 2026 12:12:30 +0000 Subject: [PATCH 08/37] optimize channel tests --- src/StackExchange.Redis/KeyNotification.cs | 58 ++++++-- .../KeyNotificationTypeFastHash.cs | 127 +++++++++--------- 2 files changed, 114 insertions(+), 71 deletions(-) diff --git a/src/StackExchange.Redis/KeyNotification.cs b/src/StackExchange.Redis/KeyNotification.cs index 47e9f287e..94f21bcc7 100644 --- a/src/StackExchange.Redis/KeyNotification.cs +++ b/src/StackExchange.Redis/KeyNotification.cs @@ -1,7 +1,7 @@ using System; using System.Buffers.Text; using System.Diagnostics; - +using static StackExchange.Redis.KeyNotificationChannels; namespace StackExchange.Redis; /// @@ -16,13 +16,26 @@ public static bool TryParse(in RedisChannel channel, in RedisValue value, out Ke { // validate that it looks reasonable var span = channel.Span; - if (span.StartsWith("__keyspace@"u8) || span.StartsWith("__keyevent@"u8)) + + const int PREFIX_LEN = KeySpaceStart.Length, MIN_LEN = PREFIX_LEN + MinSuffixBytes; // need "0__:x" or similar after prefix + Debug.Assert(KeyEventStart.Length == PREFIX_LEN); // prove these are the same, DEBUG only + + if (span.Length >= MIN_LEN) { - // check that there is *something* non-empty after the prefix, with __: as the suffix (we don't verify *what*) - if (span.Slice(11).IndexOf("__:"u8) > 0) + var prefix = span.Slice(0, PREFIX_LEN); + var hash = prefix.Hash64(); + switch (hash) { - notification = new KeyNotification(in channel, in value); - return true; + case KeySpaceStart.Hash when KeySpaceStart.Is(hash, prefix): + case KeyEventStart.Hash when KeyEventStart.Is(hash, prefix): + // check that there is *something* non-empty after the prefix, with __: as the suffix (we don't verify *what*) + if (span.Slice(PREFIX_LEN).IndexOf("__:"u8) > 0) + { + notification = new KeyNotification(in channel, in value); + return true; + } + + break; } } @@ -30,6 +43,8 @@ public static bool TryParse(in RedisChannel channel, in RedisValue value, out Ke return false; } + private const int MinSuffixBytes = 5; // need "0__:x" or similar after prefix + /// /// The channel associated with this notification. /// @@ -194,10 +209,37 @@ public KeyNotificationType Type /// /// Indicates whether this notification originated from a keyspace notification, for example __keyspace@0__:mykey with payload set. /// - public bool IsKeySpace => _channel.Span.StartsWith("__keyspace@"u8); + public bool IsKeySpace + { + get + { + var span = _channel.Span; + return span.Length >= KeySpaceStart.Length + MinSuffixBytes && KeySpaceStart.Is(span.Hash64(), span.Slice(0, KeySpaceStart.Length)); + } + } /// /// Indicates whether this notification originated from a keyevent notification, for example __keyevent@0__:set with payload mykey. /// - public bool IsKeyEvent => _channel.Span.StartsWith("__keyevent@"u8); + public bool IsKeyEvent + { + get + { + var span = _channel.Span; + return span.Length >= KeyEventStart.Length + MinSuffixBytes && KeyEventStart.Is(span.Hash64(), span.Slice(0, KeyEventStart.Length)); + } + } +} + +internal static partial class KeyNotificationChannels +{ + [FastHash("__keyspace@")] + internal static partial class KeySpaceStart + { + } + + [FastHash("__keyevent@")] + internal static partial class KeyEventStart + { + } } diff --git a/src/StackExchange.Redis/KeyNotificationTypeFastHash.cs b/src/StackExchange.Redis/KeyNotificationTypeFastHash.cs index e67de0f18..bcf08bad2 100644 --- a/src/StackExchange.Redis/KeyNotificationTypeFastHash.cs +++ b/src/StackExchange.Redis/KeyNotificationTypeFastHash.cs @@ -72,6 +72,70 @@ type_changed.Hash when type_changed.Is(hash, value) => KeyNotificationType.TypeC _ => KeyNotificationType.Unknown, }; } + + internal static ReadOnlySpan GetRawBytes(KeyNotificationType type) + { + return type switch + { + KeyNotificationType.Append => append.U8, + KeyNotificationType.Copy => copy.U8, + KeyNotificationType.Del => del.U8, + KeyNotificationType.Expire => expire.U8, + KeyNotificationType.HDel => hdel.U8, + KeyNotificationType.HExpired => hexpired.U8, + KeyNotificationType.HIncrByFloat => hincrbyfloat.U8, + KeyNotificationType.HIncrBy => hincrby.U8, + KeyNotificationType.HPersist => hpersist.U8, + KeyNotificationType.HSet => hset.U8, + KeyNotificationType.IncrByFloat => incrbyfloat.U8, + KeyNotificationType.IncrBy => incrby.U8, + KeyNotificationType.LInsert => linsert.U8, + KeyNotificationType.LPop => lpop.U8, + KeyNotificationType.LPush => lpush.U8, + KeyNotificationType.LRem => lrem.U8, + KeyNotificationType.LSet => lset.U8, + KeyNotificationType.LTrim => ltrim.U8, + KeyNotificationType.MoveFrom => move_from.U8, + KeyNotificationType.MoveTo => move_to.U8, + KeyNotificationType.Persist => persist.U8, + KeyNotificationType.RenameFrom => rename_from.U8, + KeyNotificationType.RenameTo => rename_to.U8, + KeyNotificationType.Restore => restore.U8, + KeyNotificationType.RPop => rpop.U8, + KeyNotificationType.RPush => rpush.U8, + KeyNotificationType.SAdd => sadd.U8, + KeyNotificationType.Set => set.U8, + KeyNotificationType.SetRange => setrange.U8, + KeyNotificationType.SortStore => sortstore.U8, + KeyNotificationType.SRem => srem.U8, + KeyNotificationType.SPop => spop.U8, + KeyNotificationType.XAdd => xadd.U8, + KeyNotificationType.XDel => xdel.U8, + KeyNotificationType.XGroupCreateConsumer => xgroup_createconsumer.U8, + KeyNotificationType.XGroupCreate => xgroup_create.U8, + KeyNotificationType.XGroupDelConsumer => xgroup_delconsumer.U8, + KeyNotificationType.XGroupDestroy => xgroup_destroy.U8, + KeyNotificationType.XGroupSetId => xgroup_setid.U8, + KeyNotificationType.XSetId => xsetid.U8, + KeyNotificationType.XTrim => xtrim.U8, + KeyNotificationType.ZAdd => zadd.U8, + KeyNotificationType.ZDiffStore => zdiffstore.U8, + KeyNotificationType.ZInterStore => zinterstore.U8, + KeyNotificationType.ZUnionStore => zunionstore.U8, + KeyNotificationType.ZIncr => zincr.U8, + KeyNotificationType.ZRemByRank => zrembyrank.U8, + KeyNotificationType.ZRemByScore => zrembyscore.U8, + KeyNotificationType.ZRem => zrem.U8, + KeyNotificationType.Expired => expired.U8, + KeyNotificationType.Evicted => evicted.U8, + KeyNotificationType.New => _new.U8, + KeyNotificationType.Overwritten => overwritten.U8, + KeyNotificationType.TypeChanged => type_changed.U8, + _ => Throw(), + }; + static ReadOnlySpan Throw() => throw new ArgumentOutOfRangeException(nameof(type)); + } + #pragma warning disable SA1300, CS8981 // ReSharper disable InconsistentNaming [FastHash] @@ -346,67 +410,4 @@ internal static partial class type_changed // ReSharper restore InconsistentNaming #pragma warning restore SA1300, CS8981 - - internal static ReadOnlySpan GetRawBytes(KeyNotificationType type) - { - return type switch - { - KeyNotificationType.Append => append.U8, - KeyNotificationType.Copy => copy.U8, - KeyNotificationType.Del => del.U8, - KeyNotificationType.Expire => expire.U8, - KeyNotificationType.HDel => hdel.U8, - KeyNotificationType.HExpired => hexpired.U8, - KeyNotificationType.HIncrByFloat => hincrbyfloat.U8, - KeyNotificationType.HIncrBy => hincrby.U8, - KeyNotificationType.HPersist => hpersist.U8, - KeyNotificationType.HSet => hset.U8, - KeyNotificationType.IncrByFloat => incrbyfloat.U8, - KeyNotificationType.IncrBy => incrby.U8, - KeyNotificationType.LInsert => linsert.U8, - KeyNotificationType.LPop => lpop.U8, - KeyNotificationType.LPush => lpush.U8, - KeyNotificationType.LRem => lrem.U8, - KeyNotificationType.LSet => lset.U8, - KeyNotificationType.LTrim => ltrim.U8, - KeyNotificationType.MoveFrom => move_from.U8, - KeyNotificationType.MoveTo => move_to.U8, - KeyNotificationType.Persist => persist.U8, - KeyNotificationType.RenameFrom => rename_from.U8, - KeyNotificationType.RenameTo => rename_to.U8, - KeyNotificationType.Restore => restore.U8, - KeyNotificationType.RPop => rpop.U8, - KeyNotificationType.RPush => rpush.U8, - KeyNotificationType.SAdd => sadd.U8, - KeyNotificationType.Set => set.U8, - KeyNotificationType.SetRange => setrange.U8, - KeyNotificationType.SortStore => sortstore.U8, - KeyNotificationType.SRem => srem.U8, - KeyNotificationType.SPop => spop.U8, - KeyNotificationType.XAdd => xadd.U8, - KeyNotificationType.XDel => xdel.U8, - KeyNotificationType.XGroupCreateConsumer => xgroup_createconsumer.U8, - KeyNotificationType.XGroupCreate => xgroup_create.U8, - KeyNotificationType.XGroupDelConsumer => xgroup_delconsumer.U8, - KeyNotificationType.XGroupDestroy => xgroup_destroy.U8, - KeyNotificationType.XGroupSetId => xgroup_setid.U8, - KeyNotificationType.XSetId => xsetid.U8, - KeyNotificationType.XTrim => xtrim.U8, - KeyNotificationType.ZAdd => zadd.U8, - KeyNotificationType.ZDiffStore => zdiffstore.U8, - KeyNotificationType.ZInterStore => zinterstore.U8, - KeyNotificationType.ZUnionStore => zunionstore.U8, - KeyNotificationType.ZIncr => zincr.U8, - KeyNotificationType.ZRemByRank => zrembyrank.U8, - KeyNotificationType.ZRemByScore => zrembyscore.U8, - KeyNotificationType.ZRem => zrem.U8, - KeyNotificationType.Expired => expired.U8, - KeyNotificationType.Evicted => evicted.U8, - KeyNotificationType.New => _new.U8, - KeyNotificationType.Overwritten => overwritten.U8, - KeyNotificationType.TypeChanged => type_changed.U8, - _ => Throw(), - }; - static ReadOnlySpan Throw() => throw new ArgumentOutOfRangeException(nameof(type)); - } } From 32d65c813791d06d38d5230a5b4705e32ee94517 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 23 Jan 2026 12:13:21 +0000 Subject: [PATCH 09/37] nit --- src/StackExchange.Redis/KeyNotification.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/StackExchange.Redis/KeyNotification.cs b/src/StackExchange.Redis/KeyNotification.cs index 94f21bcc7..287e18d5b 100644 --- a/src/StackExchange.Redis/KeyNotification.cs +++ b/src/StackExchange.Redis/KeyNotification.cs @@ -17,7 +17,7 @@ public static bool TryParse(in RedisChannel channel, in RedisValue value, out Ke // validate that it looks reasonable var span = channel.Span; - const int PREFIX_LEN = KeySpaceStart.Length, MIN_LEN = PREFIX_LEN + MinSuffixBytes; // need "0__:x" or similar after prefix + const int PREFIX_LEN = KeySpaceStart.Length, MIN_LEN = PREFIX_LEN + MinSuffixBytes; Debug.Assert(KeyEventStart.Length == PREFIX_LEN); // prove these are the same, DEBUG only if (span.Length >= MIN_LEN) From 54a5e4074836bb9bebb4b30566db643c3adc243f Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 23 Jan 2026 15:28:25 +0000 Subject: [PATCH 10/37] assertions for multi-node and key-routing logic --- src/StackExchange.Redis/KeyNotification.cs | 31 ++++++------- src/StackExchange.Redis/RedisChannel.cs | 2 +- .../KeyNotificationTests.cs | 46 +++++++++++++++++++ 3 files changed, 62 insertions(+), 17 deletions(-) diff --git a/src/StackExchange.Redis/KeyNotification.cs b/src/StackExchange.Redis/KeyNotification.cs index 287e18d5b..f563019f3 100644 --- a/src/StackExchange.Redis/KeyNotification.cs +++ b/src/StackExchange.Redis/KeyNotification.cs @@ -17,19 +17,18 @@ public static bool TryParse(in RedisChannel channel, in RedisValue value, out Ke // validate that it looks reasonable var span = channel.Span; - const int PREFIX_LEN = KeySpaceStart.Length, MIN_LEN = PREFIX_LEN + MinSuffixBytes; - Debug.Assert(KeyEventStart.Length == PREFIX_LEN); // prove these are the same, DEBUG only - - if (span.Length >= MIN_LEN) + // KeySpaceStart and KeyEventStart are the same size, see KeyEventPrefix_KeySpacePrefix_Length_Matches + if (span.Length >= KeySpacePrefix.Length + MinSuffixBytes) { - var prefix = span.Slice(0, PREFIX_LEN); + // check that the prefix is valid, i.e. "__keyspace@" or "__keyevent@" + var prefix = span.Slice(0, KeySpacePrefix.Length); var hash = prefix.Hash64(); switch (hash) { - case KeySpaceStart.Hash when KeySpaceStart.Is(hash, prefix): - case KeyEventStart.Hash when KeyEventStart.Is(hash, prefix): + case KeySpacePrefix.Hash when KeySpacePrefix.Is(hash, prefix): + case KeyEventPrefix.Hash when KeyEventPrefix.Is(hash, prefix): // check that there is *something* non-empty after the prefix, with __: as the suffix (we don't verify *what*) - if (span.Slice(PREFIX_LEN).IndexOf("__:"u8) > 0) + if (span.Slice(KeySpacePrefix.Length).IndexOf("__:"u8) > 0) { notification = new KeyNotification(in channel, in value); return true; @@ -74,8 +73,8 @@ public int Database { // prevalidated format, so we can just skip past the prefix (except for the default value) if (_channel.IsNull) return -1; - var span = _channel.Span.Slice(11); - var end = span.IndexOf((byte)'_'); // expecting __: + var span = _channel.Span.Slice(KeySpacePrefix.Length); // also works for KeyEventPrefix + var end = span.IndexOf((byte)'_'); // expecting "__:foo" - we'll just stop at the underscore if (end <= 0) return -1; span = span.Slice(0, end); @@ -207,26 +206,26 @@ public KeyNotificationType Type } /// - /// Indicates whether this notification originated from a keyspace notification, for example __keyspace@0__:mykey with payload set. + /// Indicates whether this notification originated from a keyspace notification, for example __keyspace@4__:mykey with payload set. /// public bool IsKeySpace { get { var span = _channel.Span; - return span.Length >= KeySpaceStart.Length + MinSuffixBytes && KeySpaceStart.Is(span.Hash64(), span.Slice(0, KeySpaceStart.Length)); + return span.Length >= KeySpacePrefix.Length + MinSuffixBytes && KeySpacePrefix.Is(span.Hash64(), span.Slice(0, KeySpacePrefix.Length)); } } /// - /// Indicates whether this notification originated from a keyevent notification, for example __keyevent@0__:set with payload mykey. + /// Indicates whether this notification originated from a keyevent notification, for example __keyevent@4__:set with payload mykey. /// public bool IsKeyEvent { get { var span = _channel.Span; - return span.Length >= KeyEventStart.Length + MinSuffixBytes && KeyEventStart.Is(span.Hash64(), span.Slice(0, KeyEventStart.Length)); + return span.Length >= KeyEventPrefix.Length + MinSuffixBytes && KeyEventPrefix.Is(span.Hash64(), span.Slice(0, KeyEventPrefix.Length)); } } } @@ -234,12 +233,12 @@ public bool IsKeyEvent internal static partial class KeyNotificationChannels { [FastHash("__keyspace@")] - internal static partial class KeySpaceStart + internal static partial class KeySpacePrefix { } [FastHash("__keyevent@")] - internal static partial class KeyEventStart + internal static partial class KeyEventPrefix { } } diff --git a/src/StackExchange.Redis/RedisChannel.cs b/src/StackExchange.Redis/RedisChannel.cs index c2e780778..f1aae29f8 100644 --- a/src/StackExchange.Redis/RedisChannel.cs +++ b/src/StackExchange.Redis/RedisChannel.cs @@ -167,7 +167,7 @@ public static RedisChannel Sharded(string value) => /// Create a key-notification channel for a single key in a single database. /// public static RedisChannel KeySpace(in RedisKey key, int database) - => BuildKeySpace(key, database, RedisChannelOptions.None); + => BuildKeySpace(key, database, RedisChannelOptions.KeyRouted); /// /// Create a key-notification channel for a pattern, optionally in a specified database. diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs index 77c253cb3..cc73fe3d7 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs @@ -409,6 +409,7 @@ public void CreateKeySpaceNotification_Valid() var channel = RedisChannel.KeySpace("abc", 42); Assert.Equal("__keyspace@42__:abc", channel.ToString()); Assert.False(channel.IsMultiNode); + Assert.True(channel.IsKeyRouted); Assert.False(channel.IsSharded); Assert.False(channel.IsPattern); } @@ -423,6 +424,7 @@ public void CreateKeySpaceNotificationPattern(string? pattern, int? database, st var channel = RedisChannel.KeySpacePattern(pattern, database); Assert.Equal(expected, channel.ToString()); Assert.True(channel.IsMultiNode); + Assert.False(channel.IsKeyRouted); Assert.False(channel.IsSharded); Assert.True(channel.IsPattern); } @@ -437,6 +439,7 @@ public void CreateKeyEventNotification(KeyNotificationType type, int? database, var channel = RedisChannel.KeyEvent(type, database); Assert.Equal(expected, channel.ToString()); Assert.True(channel.IsMultiNode); + Assert.False(channel.IsKeyRouted); Assert.False(channel.IsSharded); if (isPattern) { @@ -447,4 +450,47 @@ public void CreateKeyEventNotification(KeyNotificationType type, int? database, Assert.False(channel.IsPattern); } } + + [Fact] + public void Cannot_KeyRoute_KeySpace_SingleKeyIsKeyRouted() + { + var channel = RedisChannel.KeySpace("abc", 42); + Assert.False(channel.IsMultiNode); + Assert.True(channel.IsKeyRouted); + Assert.True(channel.WithKeyRouting().IsKeyRouted); // no change, still key-routed + } + + [Fact] + public void Cannot_KeyRoute_KeySpacePattern() + { + var channel = RedisChannel.KeySpacePattern("abc", 42); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsKeyRouted); + Assert.StartsWith("Key routing is not supported for multi-node channels", Assert.Throws(() => channel.WithKeyRouting()).Message); + } + + [Fact] + public void Cannot_KeyRoute_KeyEvent() + { + var channel = RedisChannel.KeyEvent(KeyNotificationType.Set, 42); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsKeyRouted); + Assert.StartsWith("Key routing is not supported for multi-node channels", Assert.Throws(() => channel.WithKeyRouting()).Message); + } + + [Fact] + public void Cannot_KeyRoute_KeyEvent_Custom() + { + var channel = RedisChannel.KeyEvent("foo"u8, 42); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsKeyRouted); + Assert.StartsWith("Key routing is not supported for multi-node channels", Assert.Throws(() => channel.WithKeyRouting()).Message); + } + + [Fact] + public void KeyEventPrefix_KeySpacePrefix_Length_Matches() + { + // this is a sanity check for the parsing step in KeyNotification.TryParse + Assert.Equal(KeyNotificationChannels.KeySpacePrefix.Length, KeyNotificationChannels.KeyEventPrefix.Length); + } } From 92529449684e7d4e69d3c7c79078d3bfa24c884a Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 23 Jan 2026 16:01:08 +0000 Subject: [PATCH 11/37] prevent publish on multi-node channels --- src/StackExchange.Redis/RedisChannel.cs | 12 +++++++++++- src/StackExchange.Redis/RedisDatabase.cs | 4 ++-- src/StackExchange.Redis/RedisSubscriber.cs | 4 ++-- .../KeyNotificationTests.cs | 4 ++++ 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/StackExchange.Redis/RedisChannel.cs b/src/StackExchange.Redis/RedisChannel.cs index f1aae29f8..7b208bb9d 100644 --- a/src/StackExchange.Redis/RedisChannel.cs +++ b/src/StackExchange.Redis/RedisChannel.cs @@ -29,7 +29,17 @@ internal enum RedisChannelOptions private const RedisChannelOptions EqualityMask = ~(RedisChannelOptions.KeyRouted | RedisChannelOptions.MultiNode); - internal RedisCommand PublishCommand => IsSharded ? RedisCommand.SPUBLISH : RedisCommand.PUBLISH; + internal RedisCommand GetPublishCommand() + { + return (Options & (RedisChannelOptions.Sharded | RedisChannelOptions.MultiNode)) switch + { + RedisChannelOptions.None => RedisCommand.PUBLISH, + RedisChannelOptions.Sharded => RedisCommand.SPUBLISH, + _ => ThrowKeyRouted(), + }; + + static RedisCommand ThrowKeyRouted() => throw new InvalidOperationException("Publishing is not supported for multi-node channels"); + } /// /// Should we use cluster routing for this channel? This applies *either* to sharded (SPUBLISH) scenarios, diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index c1c3c5728..056a5380a 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -1900,7 +1900,7 @@ public Task StringLongestCommonSubsequenceWithMatchesAsync(Redis public long Publish(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) { if (channel.IsNullOrEmpty) throw new ArgumentNullException(nameof(channel)); - var msg = Message.Create(-1, flags, channel.PublishCommand, channel, message); + var msg = Message.Create(-1, flags, channel.GetPublishCommand(), channel, message); // if we're actively subscribed: send via that connection (otherwise, follow normal rules) return ExecuteSync(msg, ResultProcessor.Int64, server: multiplexer.GetSubscribedServer(channel)); } @@ -1908,7 +1908,7 @@ public long Publish(RedisChannel channel, RedisValue message, CommandFlags flags public Task PublishAsync(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) { if (channel.IsNullOrEmpty) throw new ArgumentNullException(nameof(channel)); - var msg = Message.Create(-1, flags, channel.PublishCommand, channel, message); + var msg = Message.Create(-1, flags, channel.GetPublishCommand(), channel, message); // if we're actively subscribed: send via that connection (otherwise, follow normal rules) return ExecuteAsync(msg, ResultProcessor.Int64, server: multiplexer.GetSubscribedServer(channel)); } diff --git a/src/StackExchange.Redis/RedisSubscriber.cs b/src/StackExchange.Redis/RedisSubscriber.cs index 9ade78c2d..5d083164b 100644 --- a/src/StackExchange.Redis/RedisSubscriber.cs +++ b/src/StackExchange.Redis/RedisSubscriber.cs @@ -393,7 +393,7 @@ private static void ThrowIfNull(in RedisChannel channel) public long Publish(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) { ThrowIfNull(channel); - var msg = Message.Create(-1, flags, channel.PublishCommand, channel, message); + var msg = Message.Create(-1, flags, channel.GetPublishCommand(), channel, message); // if we're actively subscribed: send via that connection (otherwise, follow normal rules) return ExecuteSync(msg, ResultProcessor.Int64, server: multiplexer.GetSubscribedServer(channel)); } @@ -401,7 +401,7 @@ public long Publish(RedisChannel channel, RedisValue message, CommandFlags flags public Task PublishAsync(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) { ThrowIfNull(channel); - var msg = Message.Create(-1, flags, channel.PublishCommand, channel, message); + var msg = Message.Create(-1, flags, channel.GetPublishCommand(), channel, message); // if we're actively subscribed: send via that connection (otherwise, follow normal rules) return ExecuteAsync(msg, ResultProcessor.Int64, server: multiplexer.GetSubscribedServer(channel)); } diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs index cc73fe3d7..b9548eb44 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs @@ -458,6 +458,7 @@ public void Cannot_KeyRoute_KeySpace_SingleKeyIsKeyRouted() Assert.False(channel.IsMultiNode); Assert.True(channel.IsKeyRouted); Assert.True(channel.WithKeyRouting().IsKeyRouted); // no change, still key-routed + Assert.Equal(RedisCommand.PUBLISH, channel.GetPublishCommand()); } [Fact] @@ -467,6 +468,7 @@ public void Cannot_KeyRoute_KeySpacePattern() Assert.True(channel.IsMultiNode); Assert.False(channel.IsKeyRouted); Assert.StartsWith("Key routing is not supported for multi-node channels", Assert.Throws(() => channel.WithKeyRouting()).Message); + Assert.StartsWith("Publishing is not supported for multi-node channels", Assert.Throws(() => channel.GetPublishCommand()).Message); } [Fact] @@ -476,6 +478,7 @@ public void Cannot_KeyRoute_KeyEvent() Assert.True(channel.IsMultiNode); Assert.False(channel.IsKeyRouted); Assert.StartsWith("Key routing is not supported for multi-node channels", Assert.Throws(() => channel.WithKeyRouting()).Message); + Assert.StartsWith("Publishing is not supported for multi-node channels", Assert.Throws(() => channel.GetPublishCommand()).Message); } [Fact] @@ -485,6 +488,7 @@ public void Cannot_KeyRoute_KeyEvent_Custom() Assert.True(channel.IsMultiNode); Assert.False(channel.IsKeyRouted); Assert.StartsWith("Key routing is not supported for multi-node channels", Assert.Throws(() => channel.WithKeyRouting()).Message); + Assert.StartsWith("Publishing is not supported for multi-node channels", Assert.Throws(() => channel.GetPublishCommand()).Message); } [Fact] From 21ddfa9ff1082e891416acba158d5a59486ce948 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 27 Jan 2026 16:28:31 +0000 Subject: [PATCH 12/37] naming --- src/StackExchange.Redis/RedisSubscriber.cs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/StackExchange.Redis/RedisSubscriber.cs b/src/StackExchange.Redis/RedisSubscriber.cs index 5d083164b..30cb55a77 100644 --- a/src/StackExchange.Redis/RedisSubscriber.cs +++ b/src/StackExchange.Redis/RedisSubscriber.cs @@ -181,21 +181,25 @@ public Subscription(CommandFlags flags) /// /// Gets the configured (P)SUBSCRIBE or (P)UNSUBSCRIBE for an action. /// - internal Message GetMessage(RedisChannel channel, SubscriptionAction action, CommandFlags flags, bool internalCall) + internal Message GetSubscriptionMessage(RedisChannel channel, SubscriptionAction action, CommandFlags flags, bool internalCall) { var command = action switch // note that the Routed flag doesn't impact the message here - just the routing { SubscriptionAction.Subscribe => (channel.Options & ~RedisChannel.RedisChannelOptions.KeyRouted) switch { RedisChannel.RedisChannelOptions.None => RedisCommand.SUBSCRIBE, + RedisChannel.RedisChannelOptions.MultiNode => RedisCommand.SUBSCRIBE, RedisChannel.RedisChannelOptions.Pattern => RedisCommand.PSUBSCRIBE, + RedisChannel.RedisChannelOptions.Pattern | RedisChannel.RedisChannelOptions.MultiNode => RedisCommand.PSUBSCRIBE, RedisChannel.RedisChannelOptions.Sharded => RedisCommand.SSUBSCRIBE, _ => Unknown(action, channel.Options), }, SubscriptionAction.Unsubscribe => (channel.Options & ~RedisChannel.RedisChannelOptions.KeyRouted) switch { RedisChannel.RedisChannelOptions.None => RedisCommand.UNSUBSCRIBE, + RedisChannel.RedisChannelOptions.MultiNode => RedisCommand.UNSUBSCRIBE, RedisChannel.RedisChannelOptions.Pattern => RedisCommand.PUNSUBSCRIBE, + RedisChannel.RedisChannelOptions.Pattern | RedisChannel.RedisChannelOptions.MultiNode => RedisCommand.PUNSUBSCRIBE, RedisChannel.RedisChannelOptions.Sharded => RedisCommand.SUNSUBSCRIBE, _ => Unknown(action, channel.Options), }, @@ -432,7 +436,7 @@ internal bool EnsureSubscribedToServer(Subscription sub, RedisChannel channel, C // TODO: Cleanup old hangers here? sub.SetCurrentServer(null); // we're not appropriately connected, so blank it out for eligible reconnection - var message = sub.GetMessage(channel, SubscriptionAction.Subscribe, flags, internalCall); + var message = sub.GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall); var selected = multiplexer.SelectServer(message); return ExecuteSync(message, sub.Processor, selected); } @@ -446,7 +450,7 @@ internal void ResubscribeToServer(Subscription sub, RedisChannel channel, Server { // we'll *try* for a simple resubscribe, following any -MOVED etc, but if that fails: fall back // to full reconfigure; importantly, note that we've already recorded the disconnect - var message = sub.GetMessage(channel, SubscriptionAction.Subscribe, CommandFlags.None, false); + var message = sub.GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, CommandFlags.None, false); _ = ExecuteAsync(message, sub.Processor, serverEndPoint).ContinueWith( t => multiplexer.ReconfigureIfNeeded(serverEndPoint.EndPoint, false, cause: cause), TaskContinuationOptions.OnlyOnFaulted); @@ -486,7 +490,7 @@ public Task EnsureSubscribedToServerAsync(Subscription sub, RedisChannel c // TODO: Cleanup old hangers here? sub.SetCurrentServer(null); // we're not appropriately connected, so blank it out for eligible reconnection - var message = sub.GetMessage(channel, SubscriptionAction.Subscribe, flags, internalCall); + var message = sub.GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall); server ??= multiplexer.SelectServer(message); return ExecuteAsync(message, sub.Processor, server); } @@ -509,7 +513,7 @@ private bool UnsubscribeFromServer(Subscription sub, RedisChannel channel, Comma { if (sub.GetCurrentServer() is ServerEndPoint oldOwner) { - var message = sub.GetMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall); + var message = sub.GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall); return multiplexer.ExecuteSyncImpl(message, sub.Processor, oldOwner); } return false; @@ -531,7 +535,7 @@ private Task UnsubscribeFromServerAsync(Subscription sub, RedisChannel cha { if (sub.GetCurrentServer() is ServerEndPoint oldOwner) { - var message = sub.GetMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall); + var message = sub.GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall); return multiplexer.ExecuteAsyncImpl(message, sub.Processor, asyncState, oldOwner); } return CompletedTask.FromResult(true, asyncState); From bbeecc3d2a025ad673ea7e530a4cbab45d9c0c7f Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 28 Jan 2026 15:43:44 +0000 Subject: [PATCH 13/37] split Subscription into single/multi implementation and do the necessary --- .../ConnectionMultiplexer.cs | 1 + src/StackExchange.Redis/RedisSubscriber.cs | 240 +-------- src/StackExchange.Redis/ResultProcessor.cs | 19 +- src/StackExchange.Redis/Subscription.cs | 496 ++++++++++++++++++ .../FailoverTests.cs | 2 +- .../PubSubMultiserverTests.cs | 26 +- 6 files changed, 542 insertions(+), 242 deletions(-) create mode 100644 src/StackExchange.Redis/Subscription.cs diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index cc766338a..219ac7cb0 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -730,6 +730,7 @@ private static ConnectionMultiplexer ConnectImpl(ConfigurationOptions configurat ReadOnlySpan IInternalConnectionMultiplexer.GetServerSnapshot() => _serverSnapshot.AsSpan(); internal ReadOnlySpan GetServerSnapshot() => _serverSnapshot.AsSpan(); + internal ReadOnlyMemory GetServerSnaphotMemory() => _serverSnapshot.AsMemory(); internal sealed class ServerSnapshot : IEnumerable { public static ServerSnapshot Empty { get; } = new ServerSnapshot(Array.Empty(), 0); diff --git a/src/StackExchange.Redis/RedisSubscriber.cs b/src/StackExchange.Redis/RedisSubscriber.cs index 30cb55a77..824aef025 100644 --- a/src/StackExchange.Redis/RedisSubscriber.cs +++ b/src/StackExchange.Redis/RedisSubscriber.cs @@ -1,11 +1,8 @@ using System; using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; -using System.Diagnostics.SymbolStore; using System.Net; -using System.Threading; using System.Threading.Tasks; -using Pipelines.Sockets.Unofficial; using Pipelines.Sockets.Unofficial.Arenas; using static StackExchange.Redis.ConnectionMultiplexer; @@ -30,7 +27,7 @@ internal Subscription GetOrAddSubscription(in RedisChannel channel, CommandFlags { if (!subscriptions.TryGetValue(channel, out var sub)) { - sub = new Subscription(flags); + sub = channel.IsMultiNode ? new MultiNodeSubscription(flags) : new SingleNodeSubscription(flags); subscriptions.TryAdd(channel, sub); } return sub; @@ -71,7 +68,7 @@ internal bool GetSubscriberCounts(in RedisChannel channel, out int handlers, out { if (!channel.IsNullOrEmpty && subscriptions.TryGetValue(channel, out Subscription? sub)) { - return sub.GetCurrentServer(); + return sub.GetAnyCurrentServer(); } return null; } @@ -123,7 +120,7 @@ internal void UpdateSubscriptions() { foreach (var pair in subscriptions) { - pair.Value.UpdateServer(); + pair.Value.RemoveDisconnectedEndpoints(); } } @@ -135,13 +132,10 @@ internal long EnsureSubscriptions(CommandFlags flags = CommandFlags.None) { // TODO: Subscribe with variadic commands to reduce round trips long count = 0; + var subscriber = DefaultSubscriber; foreach (var pair in subscriptions) { - if (!pair.Value.IsConnected) - { - count++; - DefaultSubscriber.EnsureSubscribedToServer(pair.Value, pair.Key, flags, true); - } + count += pair.Value.EnsureSubscribedToServer(subscriber, pair.Key, flags, true); } return count; } @@ -151,165 +145,6 @@ internal enum SubscriptionAction Subscribe, Unsubscribe, } - - /// - /// This is the record of a single subscription to a redis server. - /// It's the singular channel (which may or may not be a pattern), to one or more handlers. - /// We subscriber to a redis server once (for all messages) and execute 1-many handlers when a message arrives. - /// - internal sealed class Subscription - { - private Action? _handlers; - private readonly object _handlersLock = new object(); - private ChannelMessageQueue? _queues; - private ServerEndPoint? CurrentServer; - public CommandFlags Flags { get; } - public ResultProcessor.TrackSubscriptionsProcessor Processor { get; } - - /// - /// Whether the we have is connected. - /// Since we clear on a disconnect, this should stay correct. - /// - internal bool IsConnected => CurrentServer?.IsSubscriberConnected == true; - - public Subscription(CommandFlags flags) - { - Flags = flags; - Processor = new ResultProcessor.TrackSubscriptionsProcessor(this); - } - - /// - /// Gets the configured (P)SUBSCRIBE or (P)UNSUBSCRIBE for an action. - /// - internal Message GetSubscriptionMessage(RedisChannel channel, SubscriptionAction action, CommandFlags flags, bool internalCall) - { - var command = action switch // note that the Routed flag doesn't impact the message here - just the routing - { - SubscriptionAction.Subscribe => (channel.Options & ~RedisChannel.RedisChannelOptions.KeyRouted) switch - { - RedisChannel.RedisChannelOptions.None => RedisCommand.SUBSCRIBE, - RedisChannel.RedisChannelOptions.MultiNode => RedisCommand.SUBSCRIBE, - RedisChannel.RedisChannelOptions.Pattern => RedisCommand.PSUBSCRIBE, - RedisChannel.RedisChannelOptions.Pattern | RedisChannel.RedisChannelOptions.MultiNode => RedisCommand.PSUBSCRIBE, - RedisChannel.RedisChannelOptions.Sharded => RedisCommand.SSUBSCRIBE, - _ => Unknown(action, channel.Options), - }, - SubscriptionAction.Unsubscribe => (channel.Options & ~RedisChannel.RedisChannelOptions.KeyRouted) switch - { - RedisChannel.RedisChannelOptions.None => RedisCommand.UNSUBSCRIBE, - RedisChannel.RedisChannelOptions.MultiNode => RedisCommand.UNSUBSCRIBE, - RedisChannel.RedisChannelOptions.Pattern => RedisCommand.PUNSUBSCRIBE, - RedisChannel.RedisChannelOptions.Pattern | RedisChannel.RedisChannelOptions.MultiNode => RedisCommand.PUNSUBSCRIBE, - RedisChannel.RedisChannelOptions.Sharded => RedisCommand.SUNSUBSCRIBE, - _ => Unknown(action, channel.Options), - }, - _ => Unknown(action, channel.Options), - }; - - // TODO: Consider flags here - we need to pass Fire and Forget, but don't want to intermingle Primary/Replica - var msg = Message.Create(-1, Flags | flags, command, channel); - msg.SetForSubscriptionBridge(); - if (internalCall) - { - msg.SetInternalCall(); - } - return msg; - } - - private RedisCommand Unknown(SubscriptionAction action, RedisChannel.RedisChannelOptions options) - => throw new ArgumentException($"Unable to determine pub/sub operation for '{action}' against '{options}'"); - - public void Add(Action? handler, ChannelMessageQueue? queue) - { - if (handler != null) - { - lock (_handlersLock) - { - _handlers += handler; - } - } - if (queue != null) - { - ChannelMessageQueue.Combine(ref _queues, queue); - } - } - - public bool Remove(Action? handler, ChannelMessageQueue? queue) - { - if (handler != null) - { - lock (_handlersLock) - { - _handlers -= handler; - } - } - if (queue != null) - { - ChannelMessageQueue.Remove(ref _queues, queue); - } - return _handlers == null & _queues == null; - } - - public ICompletable? ForInvoke(in RedisChannel channel, in RedisValue message, out ChannelMessageQueue? queues) - { - var handlers = _handlers; - queues = Volatile.Read(ref _queues); - return handlers == null ? null : new MessageCompletable(channel, message, handlers); - } - - internal void MarkCompleted() - { - lock (_handlersLock) - { - _handlers = null; - } - ChannelMessageQueue.MarkAllCompleted(ref _queues); - } - - internal void GetSubscriberCounts(out int handlers, out int queues) - { - queues = ChannelMessageQueue.Count(ref _queues); - var tmp = _handlers; - if (tmp == null) - { - handlers = 0; - } - else if (tmp.IsSingle()) - { - handlers = 1; - } - else - { - handlers = 0; - foreach (var sub in tmp.AsEnumerable()) { handlers++; } - } - } - - internal ServerEndPoint? GetCurrentServer() => Volatile.Read(ref CurrentServer); - internal void SetCurrentServer(ServerEndPoint? server) => CurrentServer = server; - // conditional clear - internal bool ClearCurrentServer(ServerEndPoint expected) - { - if (CurrentServer == expected) - { - CurrentServer = null; - return true; - } - - return false; - } - - /// - /// Evaluates state and if we're not currently connected, clears the server reference. - /// - internal void UpdateServer() - { - if (!IsConnected) - { - CurrentServer = null; - } - } - } } /// @@ -420,31 +255,20 @@ public ChannelMessageQueue Subscribe(RedisChannel channel, CommandFlags flags = return queue; } - private bool Subscribe(RedisChannel channel, Action? handler, ChannelMessageQueue? queue, CommandFlags flags) + private int Subscribe(RedisChannel channel, Action? handler, ChannelMessageQueue? queue, CommandFlags flags) { ThrowIfNull(channel); - if (handler == null && queue == null) { return true; } + if (handler == null && queue == null) { return 0; } var sub = multiplexer.GetOrAddSubscription(channel, flags); sub.Add(handler, queue); - return EnsureSubscribedToServer(sub, channel, flags, false); - } - - internal bool EnsureSubscribedToServer(Subscription sub, RedisChannel channel, CommandFlags flags, bool internalCall) - { - if (sub.IsConnected) { return true; } - - // TODO: Cleanup old hangers here? - sub.SetCurrentServer(null); // we're not appropriately connected, so blank it out for eligible reconnection - var message = sub.GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall); - var selected = multiplexer.SelectServer(message); - return ExecuteSync(message, sub.Processor, selected); + return sub.EnsureSubscribedToServer(this, channel, flags, false); } internal void ResubscribeToServer(Subscription sub, RedisChannel channel, ServerEndPoint serverEndPoint, string cause) { // conditional: only if that's the server we were connected to, or "none"; we don't want to end up duplicated - if (sub.ClearCurrentServer(serverEndPoint) || !sub.IsConnected) + if (sub.TryRemoveEndpoint(serverEndPoint) || !sub.IsConnectedAny()) { if (serverEndPoint.IsSubscriberConnected) { @@ -474,25 +298,14 @@ public async Task SubscribeAsync(RedisChannel channel, Comm return queue; } - private Task SubscribeAsync(RedisChannel channel, Action? handler, ChannelMessageQueue? queue, CommandFlags flags, ServerEndPoint? server = null) + private Task SubscribeAsync(RedisChannel channel, Action? handler, ChannelMessageQueue? queue, CommandFlags flags, ServerEndPoint? server = null) { ThrowIfNull(channel); - if (handler == null && queue == null) { return CompletedTask.Default(null); } + if (handler == null && queue == null) { return CompletedTask.Default(null); } var sub = multiplexer.GetOrAddSubscription(channel, flags); sub.Add(handler, queue); - return EnsureSubscribedToServerAsync(sub, channel, flags, false, server); - } - - public Task EnsureSubscribedToServerAsync(Subscription sub, RedisChannel channel, CommandFlags flags, bool internalCall, ServerEndPoint? server = null) - { - if (sub.IsConnected) { return CompletedTask.Default(null); } - - // TODO: Cleanup old hangers here? - sub.SetCurrentServer(null); // we're not appropriately connected, so blank it out for eligible reconnection - var message = sub.GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall); - server ??= multiplexer.SelectServer(message); - return ExecuteAsync(message, sub.Processor, server); + return sub.EnsureSubscribedToServerAsync(this, channel, flags, false, server); } public EndPoint? SubscribedEndpoint(RedisChannel channel) => multiplexer.GetSubscribedServer(channel)?.EndPoint; @@ -504,21 +317,12 @@ public bool Unsubscribe(in RedisChannel channel, Action? handler, CommandFlags flags) => UnsubscribeAsync(channel, handler, null, flags); @@ -527,20 +331,10 @@ public Task UnsubscribeAsync(in RedisChannel channel, Action.Default(asyncState); } - private Task UnsubscribeFromServerAsync(Subscription sub, RedisChannel channel, CommandFlags flags, object? asyncState, bool internalCall) - { - if (sub.GetCurrentServer() is ServerEndPoint oldOwner) - { - var message = sub.GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall); - return multiplexer.ExecuteAsyncImpl(message, sub.Processor, asyncState, oldOwner); - } - return CompletedTask.FromResult(true, asyncState); - } - /// /// Unregisters a handler or queue and returns if we should remove it from the server. /// @@ -577,7 +371,7 @@ public void UnsubscribeAll(CommandFlags flags = CommandFlags.None) if (subs.TryRemove(pair.Key, out var sub)) { sub.MarkCompleted(); - UnsubscribeFromServer(sub, pair.Key, flags, false); + sub.UnsubscribeFromServer(this, pair.Key, flags, false); } } } @@ -592,7 +386,7 @@ public Task UnsubscribeAllAsync(CommandFlags flags = CommandFlags.None) if (subs.TryRemove(pair.Key, out var sub)) { sub.MarkCompleted(); - last = UnsubscribeFromServerAsync(sub, pair.Key, flags, asyncState, false); + last = sub.UnsubscribeFromServerAsync(this, pair.Key, flags, asyncState, false); } } return last ?? CompletedTask.Default(asyncState); diff --git a/src/StackExchange.Redis/ResultProcessor.cs b/src/StackExchange.Redis/ResultProcessor.cs index 196cabde5..f2c6deb8b 100644 --- a/src/StackExchange.Redis/ResultProcessor.cs +++ b/src/StackExchange.Redis/ResultProcessor.cs @@ -469,12 +469,21 @@ protected override bool SetResultCore(PhysicalConnection connection, Message mes connection.SubscriptionCount = count; SetResult(message, true); - var newServer = message.Command switch + var ep = connection.BridgeCouldBeNull?.ServerEndPoint; + if (ep is not null) { - RedisCommand.SUBSCRIBE or RedisCommand.SSUBSCRIBE or RedisCommand.PSUBSCRIBE => connection.BridgeCouldBeNull?.ServerEndPoint, - _ => null, - }; - Subscription?.SetCurrentServer(newServer); + switch (message.Command) + { + case RedisCommand.SUBSCRIBE: + case RedisCommand.SSUBSCRIBE: + case RedisCommand.PSUBSCRIBE: + Subscription?.AddEndpoint(ep); + break; + default: + Subscription?.TryRemoveEndpoint(ep); + break; + } + } return true; } } diff --git a/src/StackExchange.Redis/Subscription.cs b/src/StackExchange.Redis/Subscription.cs new file mode 100644 index 000000000..2d877c9eb --- /dev/null +++ b/src/StackExchange.Redis/Subscription.cs @@ -0,0 +1,496 @@ +using System; +using System.Buffers; +using System.Collections.Concurrent; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Pipelines.Sockets.Unofficial; + +namespace StackExchange.Redis; + +public partial class ConnectionMultiplexer +{ + /// + /// This is the record of a single subscription to a redis server. + /// It's the singular channel (which may or may not be a pattern), to one or more handlers. + /// We subscriber to a redis server once (for all messages) and execute 1-many handlers when a message arrives. + /// + internal abstract class Subscription + { + private Action? _handlers; + private readonly object _handlersLock = new(); + private ChannelMessageQueue? _queues; + public CommandFlags Flags { get; } + public ResultProcessor.TrackSubscriptionsProcessor Processor { get; } + + internal abstract bool IsConnectedAny(); + internal abstract bool IsConnectedTo(EndPoint endpoint); + + internal abstract void AddEndpoint(ServerEndPoint server); + + // conditional clear + internal abstract bool TryRemoveEndpoint(ServerEndPoint expected); + + internal abstract void RemoveDisconnectedEndpoints(); + + // returns the number of changes required + internal abstract int EnsureSubscribedToServer( + RedisSubscriber subscriber, + in RedisChannel channel, + CommandFlags flags, + bool internalCall); + + // returns the number of changes required + internal abstract Task EnsureSubscribedToServerAsync( + RedisSubscriber subscriber, + RedisChannel channel, + CommandFlags flags, + bool internalCall, + ServerEndPoint? server = null); + + internal abstract bool UnsubscribeFromServer( + RedisSubscriber subscriber, + in RedisChannel channel, + CommandFlags flags, + bool internalCall); + + internal abstract Task UnsubscribeFromServerAsync( + RedisSubscriber subscriber, + RedisChannel channel, + CommandFlags flags, + object? asyncState, + bool internalCall); + + internal abstract int GetConnectionCount(); + + internal abstract ServerEndPoint? GetAnyCurrentServer(); + + public Subscription(CommandFlags flags) + { + Flags = flags; + Processor = new ResultProcessor.TrackSubscriptionsProcessor(this); + } + + /// + /// Gets the configured (P)SUBSCRIBE or (P)UNSUBSCRIBE for an action. + /// + internal Message GetSubscriptionMessage( + RedisChannel channel, + SubscriptionAction action, + CommandFlags flags, + bool internalCall) + { + var command = + action switch // note that the Routed flag doesn't impact the message here - just the routing + { + SubscriptionAction.Subscribe => (channel.Options & + ~RedisChannel.RedisChannelOptions + .KeyRouted) switch + { + RedisChannel.RedisChannelOptions.None => RedisCommand.SUBSCRIBE, + RedisChannel.RedisChannelOptions.MultiNode => RedisCommand.SUBSCRIBE, + RedisChannel.RedisChannelOptions.Pattern => RedisCommand.PSUBSCRIBE, + RedisChannel.RedisChannelOptions.Pattern | RedisChannel.RedisChannelOptions.MultiNode => + RedisCommand.PSUBSCRIBE, + RedisChannel.RedisChannelOptions.Sharded => RedisCommand.SSUBSCRIBE, + _ => Unknown(action, channel.Options), + }, + SubscriptionAction.Unsubscribe => (channel.Options & + ~RedisChannel.RedisChannelOptions.KeyRouted) switch + { + RedisChannel.RedisChannelOptions.None => RedisCommand.UNSUBSCRIBE, + RedisChannel.RedisChannelOptions.MultiNode => RedisCommand.UNSUBSCRIBE, + RedisChannel.RedisChannelOptions.Pattern => RedisCommand.PUNSUBSCRIBE, + RedisChannel.RedisChannelOptions.Pattern | RedisChannel.RedisChannelOptions.MultiNode => + RedisCommand.PUNSUBSCRIBE, + RedisChannel.RedisChannelOptions.Sharded => RedisCommand.SUNSUBSCRIBE, + _ => Unknown(action, channel.Options), + }, + _ => Unknown(action, channel.Options), + }; + + // TODO: Consider flags here - we need to pass Fire and Forget, but don't want to intermingle Primary/Replica + var msg = Message.Create(-1, Flags | flags, command, channel); + msg.SetForSubscriptionBridge(); + if (internalCall) + { + msg.SetInternalCall(); + } + + return msg; + } + + private RedisCommand Unknown(SubscriptionAction action, RedisChannel.RedisChannelOptions options) + => throw new ArgumentException( + $"Unable to determine pub/sub operation for '{action}' against '{options}'"); + + public void Add(Action? handler, ChannelMessageQueue? queue) + { + if (handler != null) + { + lock (_handlersLock) + { + _handlers += handler; + } + } + + if (queue != null) + { + ChannelMessageQueue.Combine(ref _queues, queue); + } + } + + public bool Remove(Action? handler, ChannelMessageQueue? queue) + { + if (handler != null) + { + lock (_handlersLock) + { + _handlers -= handler; + } + } + + if (queue != null) + { + ChannelMessageQueue.Remove(ref _queues, queue); + } + + return _handlers == null & _queues == null; + } + + public ICompletable? ForInvoke(in RedisChannel channel, in RedisValue message, out ChannelMessageQueue? queues) + { + var handlers = _handlers; + queues = Volatile.Read(ref _queues); + return handlers == null ? null : new MessageCompletable(channel, message, handlers); + } + + internal void MarkCompleted() + { + lock (_handlersLock) + { + _handlers = null; + } + + ChannelMessageQueue.MarkAllCompleted(ref _queues); + } + + internal void GetSubscriberCounts(out int handlers, out int queues) + { + queues = ChannelMessageQueue.Count(ref _queues); + var tmp = _handlers; + if (tmp == null) + { + handlers = 0; + } + else if (tmp.IsSingle()) + { + handlers = 1; + } + else + { + handlers = 0; + foreach (var sub in tmp.AsEnumerable()) { handlers++; } + } + } + } + + // used for most subscriptions; routed to a single node + internal sealed class SingleNodeSubscription(CommandFlags flags) : Subscription(flags) + { + internal override bool IsConnectedAny() => _currentServer is { IsSubscriberConnected: true }; + + internal override int GetConnectionCount() => IsConnectedAny() ? 1 : 0; + + internal override bool IsConnectedTo(EndPoint endpoint) + { + var server = _currentServer; + return server is { IsSubscriberConnected: true } && server.EndPoint == endpoint; + } + + internal override void AddEndpoint(ServerEndPoint server) => _currentServer = server; + + internal override bool TryRemoveEndpoint(ServerEndPoint expected) + { + if (_currentServer == expected) + { + _currentServer = null; + return true; + } + + return false; + } + + internal override bool UnsubscribeFromServer( + RedisSubscriber subscriber, + in RedisChannel channel, + CommandFlags flags, + bool internalCall) + { + var server = _currentServer; + if (server is not null) + { + var message = GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall); + return subscriber.multiplexer.ExecuteSyncImpl(message, Processor, server); + } + + return true; + } + + internal override Task UnsubscribeFromServerAsync( + RedisSubscriber subscriber, + RedisChannel channel, + CommandFlags flags, + object? asyncState, + bool internalCall) + { + var server = _currentServer; + if (server is not null) + { + var message = GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall); + return subscriber.multiplexer.ExecuteAsyncImpl(message, Processor, asyncState, server); + } + + return CompletedTask.FromResult(true, asyncState); + } + + private ServerEndPoint? _currentServer; + internal ServerEndPoint? GetCurrentServer() => Volatile.Read(ref _currentServer); + + internal override ServerEndPoint? GetAnyCurrentServer() => Volatile.Read(ref _currentServer); + + /// + /// Evaluates state and if we're not currently connected, clears the server reference. + /// + internal override void RemoveDisconnectedEndpoints() + { + var server = _currentServer; + if (server is { IsSubscriberConnected: false }) + { + _currentServer = null; + } + } + + internal override int EnsureSubscribedToServer( + RedisSubscriber subscriber, + in RedisChannel channel, + CommandFlags flags, + bool internalCall) + { + if (IsConnectedAny()) return 0; + + // we're not appropriately connected, so blank it out for eligible reconnection + _currentServer = null; + var message = GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall); + var selected = subscriber.multiplexer.SelectServer(message); + _ = subscriber.ExecuteSync(message, Processor, selected); + return 1; + } + + internal override async Task EnsureSubscribedToServerAsync( + RedisSubscriber subscriber, + RedisChannel channel, + CommandFlags flags, + bool internalCall, + ServerEndPoint? server = null) + { + if (IsConnectedAny()) return 0; + + // we're not appropriately connected, so blank it out for eligible reconnection + _currentServer = null; + var message = GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall); + server ??= subscriber.multiplexer.SelectServer(message); + await subscriber.ExecuteAsync(message, Processor, server).ForAwait(); + return 1; + } + } + + // used for keyspace subscriptions, which are routed to multiple nodes + internal sealed class MultiNodeSubscription(CommandFlags flags) : Subscription(flags) + { + private readonly ConcurrentDictionary _servers = new(); + + internal override bool IsConnectedAny() + { + foreach (var server in _servers) + { + if (server.Value is { IsSubscriberConnected: true }) return true; + } + + return false; + } + + internal override int GetConnectionCount() + { + int count = 0; + foreach (var server in _servers) + { + if (server.Value is { IsSubscriberConnected: true }) count++; + } + + return count; + } + + internal override bool IsConnectedTo(EndPoint endpoint) + => _servers.TryGetValue(endpoint, out var server) + && server.IsSubscriberConnected; + + internal override void AddEndpoint(ServerEndPoint server) + { + var ep = server.EndPoint; + if (!_servers.TryAdd(ep, server)) + { + _servers[ep] = server; + } + } + + internal override bool TryRemoveEndpoint(ServerEndPoint expected) + { + return _servers.TryRemove(expected.EndPoint, out _); + } + + internal override ServerEndPoint? GetAnyCurrentServer() + { + ServerEndPoint? last = null; + // prefer actively connected servers, but settle for anything + foreach (var server in _servers) + { + last = server.Value; + if (last is { IsSubscriberConnected: true }) + { + break; + } + } + + return last; + } + + internal override void RemoveDisconnectedEndpoints() + { + // This looks more complicated than it is, because of avoiding mutating the collection + // while iterating; instead, buffer any removals in a scratch buffer, and remove them in a second pass. + EndPoint[] scratch = []; + int count = 0; + foreach (var server in _servers) + { + if (server.Value.IsSubscriberConnected) + { + // flag for removal + if (scratch.Length == count) // need to resize the scratch buffer, using the pool + { + // let the array pool worry about min-sizing etc + var newLease = ArrayPool.Shared.Rent(count + 1); + scratch.CopyTo(newLease, 0); + ArrayPool.Shared.Return(scratch); + scratch = newLease; + } + + scratch[count++] = server.Key; + } + } + + // did we find anything to remove? + if (count != 0) + { + foreach (var ep in scratch.AsSpan(0, count)) + { + _servers.TryRemove(ep, out _); + } + } + + ArrayPool.Shared.Return(scratch); + } + + internal override int EnsureSubscribedToServer( + RedisSubscriber subscriber, + in RedisChannel channel, + CommandFlags flags, + bool internalCall) + { + int delta = 0; + var muxer = subscriber.multiplexer; + foreach (var server in muxer.GetServerSnapshot()) + { + // exclude sentinel, and only use replicas if we're explicitly asking for them + bool useReplica = (Flags & CommandFlags.DemandReplica) != 0; + if (server.ServerType != ServerType.Sentinel & server.IsReplica == useReplica) + { + if (!IsConnectedTo(server.EndPoint)) + { + var message = GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall); + subscriber.ExecuteSync(message, Processor, server); + delta++; + } + } + } + + return delta; + } + + internal override async Task EnsureSubscribedToServerAsync( + RedisSubscriber subscriber, + RedisChannel channel, + CommandFlags flags, + bool internalCall, + ServerEndPoint? server = null) + { + int delta = 0; + var muxer = subscriber.multiplexer; + var snapshot = muxer.GetServerSnaphotMemory(); + var len = snapshot.Length; + for (int i = 0; i < len; i++) + { + var loopServer = snapshot.Span[i]; // spans and async do not mix well + if (server is null || server == loopServer) + { + // exclude sentinel, and only use replicas if we're explicitly asking for them + bool useReplica = (Flags & CommandFlags.DemandReplica) != 0; + if (loopServer.ServerType != ServerType.Sentinel & loopServer.IsReplica == useReplica) + { + if (!IsConnectedTo(loopServer.EndPoint)) + { + var message = GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall); + await subscriber.ExecuteAsync(message, Processor, loopServer).ForAwait(); + delta++; + } + } + } + } + + return delta; + } + + internal override bool UnsubscribeFromServer( + RedisSubscriber subscriber, + in RedisChannel channel, + CommandFlags flags, + bool internalCall) + { + bool any = false; + foreach (var server in _servers) + { + var message = GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall); + any |= subscriber.ExecuteSync(message, Processor, server.Value); + } + + return any; + } + + internal override async Task UnsubscribeFromServerAsync( + RedisSubscriber subscriber, + RedisChannel channel, + CommandFlags flags, + object? asyncState, + bool internalCall) + { + bool any = false; + foreach (var server in _servers) + { + var message = GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall); + any |= await subscriber.ExecuteAsync(message, Processor, server.Value).ForAwait(); + } + + return any; + } + } +} diff --git a/tests/StackExchange.Redis.Tests/FailoverTests.cs b/tests/StackExchange.Redis.Tests/FailoverTests.cs index 1f33275b5..33b24f16f 100644 --- a/tests/StackExchange.Redis.Tests/FailoverTests.cs +++ b/tests/StackExchange.Redis.Tests/FailoverTests.cs @@ -263,7 +263,7 @@ public async Task SubscriptionsSurviveConnectionFailureAsync() foreach (var pair in muxerSubs) { var muxerSub = pair.Value; - Log($" Muxer Sub: {pair.Key}: (EndPoint: {muxerSub.GetCurrentServer()}, Connected: {muxerSub.IsConnected})"); + Log($" Muxer Sub: {pair.Key}: (EndPoint: {muxerSub.GetAnyCurrentServer()}, Connected: {muxerSub.IsConnectedAny()})"); } Log("Publishing"); diff --git a/tests/StackExchange.Redis.Tests/PubSubMultiserverTests.cs b/tests/StackExchange.Redis.Tests/PubSubMultiserverTests.cs index 43bb4b2b8..691232218 100644 --- a/tests/StackExchange.Redis.Tests/PubSubMultiserverTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubMultiserverTests.cs @@ -63,7 +63,7 @@ await sub.SubscribeAsync(channel, (_, val) => Assert.True(subscribedServerEndpoint.IsSubscriberConnected, "subscribedServerEndpoint.IsSubscriberConnected"); Assert.True(conn.GetSubscriptions().TryGetValue(channel, out var subscription)); - var initialServer = subscription.GetCurrentServer(); + var initialServer = subscription.GetAnyCurrentServer(); Assert.NotNull(initialServer); Assert.True(initialServer.IsConnected); Log("Connected to: " + initialServer); @@ -83,10 +83,10 @@ await sub.SubscribeAsync(channel, (_, val) => Assert.True(subscribedServerEndpoint.IsConnected, "subscribedServerEndpoint.IsConnected"); Assert.False(subscribedServerEndpoint.IsSubscriberConnected, "subscribedServerEndpoint.IsSubscriberConnected"); } - await UntilConditionAsync(TimeSpan.FromSeconds(5), () => subscription.IsConnected); - Assert.True(subscription.IsConnected); + await UntilConditionAsync(TimeSpan.FromSeconds(5), () => subscription.IsConnectedAny()); + Assert.True(subscription.IsConnectedAny()); - var newServer = subscription.GetCurrentServer(); + var newServer = subscription.GetAnyCurrentServer(); Assert.NotNull(newServer); Assert.NotEqual(newServer, initialServer); Log("Now connected to: " + newServer); @@ -148,7 +148,7 @@ await sub.SubscribeAsync( Assert.True(subscribedServerEndpoint.IsSubscriberConnected, "subscribedServerEndpoint.IsSubscriberConnected"); Assert.True(conn.GetSubscriptions().TryGetValue(channel, out var subscription)); - var initialServer = subscription.GetCurrentServer(); + var initialServer = subscription.GetAnyCurrentServer(); Assert.NotNull(initialServer); Assert.True(initialServer.IsConnected); Log("Connected to: " + initialServer); @@ -169,10 +169,10 @@ await sub.SubscribeAsync( if (expectSuccess) { - await UntilConditionAsync(TimeSpan.FromSeconds(5), () => subscription.IsConnected); - Assert.True(subscription.IsConnected); + await UntilConditionAsync(TimeSpan.FromSeconds(5), () => subscription.IsConnectedAny()); + Assert.True(subscription.IsConnectedAny()); - var newServer = subscription.GetCurrentServer(); + var newServer = subscription.GetAnyCurrentServer(); Assert.NotNull(newServer); Assert.NotEqual(newServer, initialServer); Log("Now connected to: " + newServer); @@ -180,16 +180,16 @@ await sub.SubscribeAsync( else { // This subscription shouldn't be able to reconnect by flags (demanding an unavailable server) - await UntilConditionAsync(TimeSpan.FromSeconds(5), () => subscription.IsConnected); - Assert.False(subscription.IsConnected); + await UntilConditionAsync(TimeSpan.FromSeconds(5), () => subscription.IsConnectedAny()); + Assert.False(subscription.IsConnectedAny()); Log("Unable to reconnect (as expected)"); // Allow connecting back to the original conn.AllowConnect = true; - await UntilConditionAsync(TimeSpan.FromSeconds(5), () => subscription.IsConnected); - Assert.True(subscription.IsConnected); + await UntilConditionAsync(TimeSpan.FromSeconds(5), () => subscription.IsConnectedAny()); + Assert.True(subscription.IsConnectedAny()); - var newServer = subscription.GetCurrentServer(); + var newServer = subscription.GetAnyCurrentServer(); Assert.NotNull(newServer); Assert.Equal(newServer, initialServer); Log("Now connected to: " + newServer); From d1ff9843fb9e5784430279b66c2f20b4d0b61fbf Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 28 Jan 2026 16:59:55 +0000 Subject: [PATCH 14/37] initial tests; requires CI changes to be applied --- .../PubSubKeyNotificationTests.cs | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs diff --git a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs new file mode 100644 index 000000000..d99c687dc --- /dev/null +++ b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests; + +public sealed class PubSubKeyNotificationTestsCluster(ITestOutputHelper output, SharedConnectionFixture fixture) + : PubSubKeyNotificationTests(output, fixture) +{ + protected override string GetConfiguration() => TestConfig.Current.ClusterServersAndPorts; +} + +public sealed class PubSubKeyNotificationTestsStandalone(ITestOutputHelper output, SharedConnectionFixture fixture) + : PubSubKeyNotificationTests(output, fixture) +{ +} + +public abstract class PubSubKeyNotificationTests(ITestOutputHelper output, SharedConnectionFixture? fixture = null) + : TestBase(output, fixture) +{ + private const int DefaultKeyCount = 10; + private const int DefaultEventCount = 512; + + private RedisKey[] InventKeys(int count = DefaultKeyCount) + { + RedisKey[] keys = new RedisKey[count]; + for (int i = 0; i < count; i++) + { + keys[i] = Guid.NewGuid().ToString(); + } + return keys; + } + + private RedisKey SelectKey(RedisKey[] keys) => keys[SharedRandom.Next(0, keys.Length)]; + +#if NET6_0_OR_GREATER + private static Random SharedRandom => Random.Shared; +#else + private static Random SharedRandom { get; } = new(); +#endif + + [Fact] + public async Task KeySpace_Events_Enabled() + { + // see https://redis.io/docs/latest/develop/pubsub/keyspace-notifications/#configuration + await using var conn = Create(allowAdmin: true); + int failures = 0; + foreach (var ep in conn.GetEndPoints()) + { + var server = conn.GetServer(ep); + var config = (await server.ConfigGetAsync("notify-keyspace-events")).Single(); + Log($"[{Format.ToString(ep)}] notify-keyspace-events: '{config.Value}'"); + + // this is a very broad config, but it's what we use in CI (and probably a common basic config) + if (config.Value != "AKE") + { + failures++; + } + } + // for details, check the log output + Assert.Equal(0, failures); + } + + [Fact] + public async Task KeySpace_CanSubscribe_ManualPublish() + { + await using var conn = Create(); + var db = conn.GetDatabase(); + + var channel = RedisChannel.KeyEvent("nonesuch"u8, database: null); + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + + int count = 0; + await sub.SubscribeAsync(channel, (_, _) => Interlocked.Increment(ref count)); + + // to publish, we need to remove the marker that this is a multi-node channel + var asLiteral = RedisChannel.Literal(channel.ToString()); + await sub.PublishAsync(asLiteral, Guid.NewGuid().ToString()); + + int expected = GetConnectedCount(conn, channel); + await Task.Delay(100).ForAwait(); + Assert.Equal(expected, count); + } + + // this looks past the horizon to see how many connections we actually have for a given channel, + // which could be more than 1 in a cluster scenario + private static int GetConnectedCount(IConnectionMultiplexer muxer, in RedisChannel channel) + => muxer is ConnectionMultiplexer typed && typed.TryGetSubscription(channel, out var sub) + ? sub.GetConnectionCount() : 1; + + [Fact] + public async Task KeyEvent_CanObserveSimple_ViaCallbackHandler() + { + await using var conn = Create(); + var db = conn.GetDatabase(); + + var keys = InventKeys(); + var channel = RedisChannel.KeyEvent(KeyNotificationType.SAdd); + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + HashSet observedKeys = []; + int count = 0, callbackCount = 0; + TaskCompletionSource allDone = new(); + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + Interlocked.Increment(ref callbackCount); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification is { IsKeyEvent: true, Type: KeyNotificationType.SAdd }) + { + var recvKey = notification.GetKey(); + lock (observedKeys) + { + int currentCount = ++count; + var newKey = observedKeys.Add(recvKey); + if (newKey) + { + Log($"Observed key: '{recvKey}' after {currentCount} events"); + } + + if (currentCount == DefaultEventCount) + { + allDone.TrySetResult(true); + } + } + } + }); + + await Task.Delay(300).ForAwait(); // give it a moment to settle + + HashSet sentKeys = new(keys.Length); + for (int i = 0; i < DefaultEventCount; i++) + { + var key = SelectKey(keys); + await db.SetAddAsync(key, i); + sentKeys.Add(key); // just in case Random has a bad day (obvious Dilbert link is obvious) + } + + // Wait for all events to be observed + try + { + Assert.True(await allDone.Task.WithTimeout(5000)); + } + catch (TimeoutException ex) + { + // if this is zero, the real problem is probably ala KeySpace_Events_Enabled + throw new TimeoutException($"Timeout; {Volatile.Read(ref callbackCount)} events observed", ex); + } + + lock (observedKeys) + { + Assert.Equal(sentKeys.Count, observedKeys.Count); + foreach (var key in sentKeys) + { + Assert.Contains(key, observedKeys); + } + } + } +} From 0fe83fcec259d9ca56b7b12f94a063d3ba232164 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 29 Jan 2026 10:18:21 +0000 Subject: [PATCH 15/37] enable key notifications in CI --- tests/RedisConfigs/3.0.503/redis.windows-service.conf | 2 +- tests/RedisConfigs/3.0.503/redis.windows.conf | 2 +- tests/RedisConfigs/Basic/primary-6379-3.0.conf | 3 ++- tests/RedisConfigs/Basic/primary-6379.conf | 3 ++- tests/RedisConfigs/Basic/replica-6380.conf | 3 ++- tests/RedisConfigs/Basic/secure-6381.conf | 3 ++- tests/RedisConfigs/Basic/tls-ciphers-6384.conf | 1 + tests/RedisConfigs/Cluster/cluster-7000.conf | 3 ++- tests/RedisConfigs/Cluster/cluster-7001.conf | 3 ++- tests/RedisConfigs/Cluster/cluster-7002.conf | 3 ++- tests/RedisConfigs/Cluster/cluster-7003.conf | 3 ++- tests/RedisConfigs/Cluster/cluster-7004.conf | 3 ++- tests/RedisConfigs/Cluster/cluster-7005.conf | 3 ++- tests/RedisConfigs/Failover/primary-6382.conf | 3 ++- tests/RedisConfigs/Failover/replica-6383.conf | 3 ++- tests/RedisConfigs/Sentinel/redis-7010.conf | 3 ++- tests/RedisConfigs/Sentinel/redis-7011.conf | 3 ++- 17 files changed, 31 insertions(+), 16 deletions(-) diff --git a/tests/RedisConfigs/3.0.503/redis.windows-service.conf b/tests/RedisConfigs/3.0.503/redis.windows-service.conf index ed44371a3..b374dad58 100644 --- a/tests/RedisConfigs/3.0.503/redis.windows-service.conf +++ b/tests/RedisConfigs/3.0.503/redis.windows-service.conf @@ -829,7 +829,7 @@ latency-monitor-threshold 0 # By default all notifications are disabled because most users don't need # this feature and the feature has some overhead. Note that if you don't # specify at least one of K or E, no events will be delivered. -notify-keyspace-events "" +notify-keyspace-events "AKE" ############################### ADVANCED CONFIG ############################### diff --git a/tests/RedisConfigs/3.0.503/redis.windows.conf b/tests/RedisConfigs/3.0.503/redis.windows.conf index c07a7e9ab..4a99b8fdb 100644 --- a/tests/RedisConfigs/3.0.503/redis.windows.conf +++ b/tests/RedisConfigs/3.0.503/redis.windows.conf @@ -829,7 +829,7 @@ latency-monitor-threshold 0 # By default all notifications are disabled because most users don't need # this feature and the feature has some overhead. Note that if you don't # specify at least one of K or E, no events will be delivered. -notify-keyspace-events "" +notify-keyspace-events "AKE" ############################### ADVANCED CONFIG ############################### diff --git a/tests/RedisConfigs/Basic/primary-6379-3.0.conf b/tests/RedisConfigs/Basic/primary-6379-3.0.conf index 1f4d96da5..889756fec 100644 --- a/tests/RedisConfigs/Basic/primary-6379-3.0.conf +++ b/tests/RedisConfigs/Basic/primary-6379-3.0.conf @@ -6,4 +6,5 @@ maxmemory 6gb dir "../Temp" appendonly no dbfilename "primary-6379.rdb" -save "" \ No newline at end of file +save "" +notify-keyspace-events AKE \ No newline at end of file diff --git a/tests/RedisConfigs/Basic/primary-6379.conf b/tests/RedisConfigs/Basic/primary-6379.conf index dee83828c..2da592601 100644 --- a/tests/RedisConfigs/Basic/primary-6379.conf +++ b/tests/RedisConfigs/Basic/primary-6379.conf @@ -7,4 +7,5 @@ dir "../Temp" appendonly no dbfilename "primary-6379.rdb" save "" -enable-debug-command yes \ No newline at end of file +enable-debug-command yes +notify-keyspace-events AKE \ No newline at end of file diff --git a/tests/RedisConfigs/Basic/replica-6380.conf b/tests/RedisConfigs/Basic/replica-6380.conf index 8d87e54c2..0c1650513 100644 --- a/tests/RedisConfigs/Basic/replica-6380.conf +++ b/tests/RedisConfigs/Basic/replica-6380.conf @@ -7,4 +7,5 @@ maxmemory 2gb appendonly no dir "../Temp" dbfilename "replica-6380.rdb" -save "" \ No newline at end of file +save "" +notify-keyspace-events AKE \ No newline at end of file diff --git a/tests/RedisConfigs/Basic/secure-6381.conf b/tests/RedisConfigs/Basic/secure-6381.conf index bd9359244..ad2e380ad 100644 --- a/tests/RedisConfigs/Basic/secure-6381.conf +++ b/tests/RedisConfigs/Basic/secure-6381.conf @@ -4,4 +4,5 @@ databases 2000 maxmemory 512mb dir "../Temp" dbfilename "secure-6381.rdb" -save "" \ No newline at end of file +save "" +notify-keyspace-events AKE \ No newline at end of file diff --git a/tests/RedisConfigs/Basic/tls-ciphers-6384.conf b/tests/RedisConfigs/Basic/tls-ciphers-6384.conf index 52fc7d7b1..857d5c741 100644 --- a/tests/RedisConfigs/Basic/tls-ciphers-6384.conf +++ b/tests/RedisConfigs/Basic/tls-ciphers-6384.conf @@ -9,3 +9,4 @@ tls-protocols "TLSv1.2 TLSv1.3" tls-cert-file /Certs/redis.crt tls-key-file /Certs/redis.key tls-ca-cert-file /Certs/ca.crt +notify-keyspace-events AKE diff --git a/tests/RedisConfigs/Cluster/cluster-7000.conf b/tests/RedisConfigs/Cluster/cluster-7000.conf index f250a3db3..ad11a23fd 100644 --- a/tests/RedisConfigs/Cluster/cluster-7000.conf +++ b/tests/RedisConfigs/Cluster/cluster-7000.conf @@ -6,4 +6,5 @@ cluster-node-timeout 5000 appendonly yes dbfilename "dump-7000.rdb" appendfilename "appendonly-7000.aof" -save "" \ No newline at end of file +save "" +notify-keyspace-events AKE \ No newline at end of file diff --git a/tests/RedisConfigs/Cluster/cluster-7001.conf b/tests/RedisConfigs/Cluster/cluster-7001.conf index 1ae0c6f83..589f9ea23 100644 --- a/tests/RedisConfigs/Cluster/cluster-7001.conf +++ b/tests/RedisConfigs/Cluster/cluster-7001.conf @@ -6,4 +6,5 @@ cluster-node-timeout 5000 appendonly yes dbfilename "dump-7001.rdb" appendfilename "appendonly-7001.aof" -save "" \ No newline at end of file +save "" +notify-keyspace-events AKE \ No newline at end of file diff --git a/tests/RedisConfigs/Cluster/cluster-7002.conf b/tests/RedisConfigs/Cluster/cluster-7002.conf index 897301f59..66a376865 100644 --- a/tests/RedisConfigs/Cluster/cluster-7002.conf +++ b/tests/RedisConfigs/Cluster/cluster-7002.conf @@ -6,4 +6,5 @@ cluster-node-timeout 5000 appendonly yes dbfilename "dump-7002.rdb" appendfilename "appendonly-7002.aof" -save "" \ No newline at end of file +save "" +notify-keyspace-events AKE \ No newline at end of file diff --git a/tests/RedisConfigs/Cluster/cluster-7003.conf b/tests/RedisConfigs/Cluster/cluster-7003.conf index 0b51677fd..1f4883023 100644 --- a/tests/RedisConfigs/Cluster/cluster-7003.conf +++ b/tests/RedisConfigs/Cluster/cluster-7003.conf @@ -6,4 +6,5 @@ cluster-node-timeout 5000 appendonly yes dbfilename "dump-7003.rdb" appendfilename "appendonly-7003.aof" -save "" \ No newline at end of file +save "" +notify-keyspace-events AKE \ No newline at end of file diff --git a/tests/RedisConfigs/Cluster/cluster-7004.conf b/tests/RedisConfigs/Cluster/cluster-7004.conf index 9a49d21f5..93d75f38a 100644 --- a/tests/RedisConfigs/Cluster/cluster-7004.conf +++ b/tests/RedisConfigs/Cluster/cluster-7004.conf @@ -6,4 +6,5 @@ cluster-node-timeout 5000 appendonly yes dbfilename "dump-7004.rdb" appendfilename "appendonly-7004.aof" -save "" \ No newline at end of file +save "" +notify-keyspace-events AKE \ No newline at end of file diff --git a/tests/RedisConfigs/Cluster/cluster-7005.conf b/tests/RedisConfigs/Cluster/cluster-7005.conf index b333a4b44..c9b5d55e2 100644 --- a/tests/RedisConfigs/Cluster/cluster-7005.conf +++ b/tests/RedisConfigs/Cluster/cluster-7005.conf @@ -6,4 +6,5 @@ cluster-node-timeout 5000 appendonly yes dbfilename "dump-7005.rdb" appendfilename "appendonly-7005.aof" -save "" \ No newline at end of file +save "" +notify-keyspace-events AKE \ No newline at end of file diff --git a/tests/RedisConfigs/Failover/primary-6382.conf b/tests/RedisConfigs/Failover/primary-6382.conf index c19e8c701..6055c0347 100644 --- a/tests/RedisConfigs/Failover/primary-6382.conf +++ b/tests/RedisConfigs/Failover/primary-6382.conf @@ -6,4 +6,5 @@ maxmemory 2gb dir "../Temp" appendonly no dbfilename "primary-6382.rdb" -save "" \ No newline at end of file +save "" +notify-keyspace-events AKE \ No newline at end of file diff --git a/tests/RedisConfigs/Failover/replica-6383.conf b/tests/RedisConfigs/Failover/replica-6383.conf index 6f1a0fc7d..e07f5a69d 100644 --- a/tests/RedisConfigs/Failover/replica-6383.conf +++ b/tests/RedisConfigs/Failover/replica-6383.conf @@ -7,4 +7,5 @@ maxmemory 2gb appendonly no dir "../Temp" dbfilename "replica-6383.rdb" -save "" \ No newline at end of file +save "" +notify-keyspace-events AKE \ No newline at end of file diff --git a/tests/RedisConfigs/Sentinel/redis-7010.conf b/tests/RedisConfigs/Sentinel/redis-7010.conf index 0e27680b2..878160632 100644 --- a/tests/RedisConfigs/Sentinel/redis-7010.conf +++ b/tests/RedisConfigs/Sentinel/redis-7010.conf @@ -5,4 +5,5 @@ maxmemory 100mb appendonly no dir "../Temp" dbfilename "sentinel-target-7010.rdb" -save "" \ No newline at end of file +save "" +notify-keyspace-events AKE \ No newline at end of file diff --git a/tests/RedisConfigs/Sentinel/redis-7011.conf b/tests/RedisConfigs/Sentinel/redis-7011.conf index 6d02eb150..08b8dad1a 100644 --- a/tests/RedisConfigs/Sentinel/redis-7011.conf +++ b/tests/RedisConfigs/Sentinel/redis-7011.conf @@ -6,4 +6,5 @@ maxmemory 100mb appendonly no dir "../Temp" dbfilename "sentinel-target-7011.rdb" -save "" \ No newline at end of file +save "" +notify-keyspace-events AKE \ No newline at end of file From 6e487b4648e9d130bb1df1eab94978a871b16835 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 29 Jan 2026 12:07:57 +0000 Subject: [PATCH 16/37] implement alt-lookup-friendly API --- src/StackExchange.Redis/Format.cs | 64 +++++++++ src/StackExchange.Redis/FrameworkShims.cs | 39 ++++++ src/StackExchange.Redis/KeyNotification.cs | 112 ++++++++++++++-- .../PublicAPI/PublicAPI.Unshipped.txt | 10 +- src/StackExchange.Redis/RedisValue.cs | 121 ++++++++++++++++-- .../Certificates/CertValidationTests.cs | 2 + .../KeyNotificationTests.cs | 6 +- .../PubSubKeyNotificationTests.cs | 75 ++++++++--- tests/StackExchange.Redis.Tests/SSLTests.cs | 2 + .../StackExchange.Redis.Tests.csproj | 2 +- .../SyncContextTests.cs | 2 +- 11 files changed, 387 insertions(+), 48 deletions(-) diff --git a/src/StackExchange.Redis/Format.cs b/src/StackExchange.Redis/Format.cs index 86aa9910d..a76b77afc 100644 --- a/src/StackExchange.Redis/Format.cs +++ b/src/StackExchange.Redis/Format.cs @@ -468,6 +468,31 @@ internal static int FormatDouble(double value, Span destination) #endif } + internal static int FormatDouble(double value, Span destination) + { + string s; + if (double.IsInfinity(value)) + { + s = double.IsPositiveInfinity(value) ? "+inf" : "-inf"; + if (!s.AsSpan().TryCopyTo(destination)) ThrowFormatFailed(); + return 4; + } + +#if NET + if (!value.TryFormat(destination, out int len, "G17", NumberFormatInfo.InvariantInfo)) + { + ThrowFormatFailed(); + } + + return len; +#else + s = value.ToString("G17", NumberFormatInfo.InvariantInfo); // this looks inefficient, but is how Utf8Formatter works too, just: more direct + if (s.Length > destination.Length) ThrowFormatFailed(); + s.AsSpan().CopyTo(destination); + return s.Length; +#endif + } + internal static int MeasureInt64(long value) { Span valueSpan = stackalloc byte[MaxInt64TextLen]; @@ -481,12 +506,38 @@ internal static int FormatInt64(long value, Span destination) return len; } + internal static int FormatInt64(long value, Span destination) + { +#if NET + if (!value.TryFormat(destination, out var len)) + ThrowFormatFailed(); + return len; +#else + Span buffer = stackalloc byte[MaxInt64TextLen]; + var bytes = FormatInt64(value, buffer); + return Encoding.UTF8.GetChars(buffer.Slice(0, bytes), destination); +#endif + } + internal static int MeasureUInt64(ulong value) { Span valueSpan = stackalloc byte[MaxInt64TextLen]; return FormatUInt64(value, valueSpan); } + internal static int FormatUInt64(ulong value, Span destination) + { +#if NET + if (!value.TryFormat(destination, out var len)) + ThrowFormatFailed(); + return len; +#else + Span buffer = stackalloc byte[MaxInt64TextLen]; + var bytes = FormatUInt64(value, buffer); + return Encoding.UTF8.GetChars(buffer.Slice(0, bytes), destination); +#endif + } + internal static int FormatUInt64(ulong value, Span destination) { if (!Utf8Formatter.TryFormat(value, destination, out var len)) @@ -501,6 +552,19 @@ internal static int FormatInt32(int value, Span destination) return len; } + internal static int FormatInt32(int value, Span destination) + { +#if NET + if (!value.TryFormat(destination, out var len)) + ThrowFormatFailed(); + return len; +#else + Span buffer = stackalloc byte[MaxInt32TextLen]; + var bytes = FormatInt32(value, buffer); + return Encoding.UTF8.GetChars(buffer.Slice(0, bytes), destination); +#endif + } + internal static bool TryParseVersion(ReadOnlySpan input, [NotNullWhen(true)] out Version? version) { #if NETCOREAPP3_1_OR_GREATER diff --git a/src/StackExchange.Redis/FrameworkShims.cs b/src/StackExchange.Redis/FrameworkShims.cs index 9472df9ae..c0fe4cb1d 100644 --- a/src/StackExchange.Redis/FrameworkShims.cs +++ b/src/StackExchange.Redis/FrameworkShims.cs @@ -15,6 +15,18 @@ internal static class IsExternalInit { } } #endif +#if !NET9_0_OR_GREATER +namespace System.Runtime.CompilerServices +{ + // see https://learn.microsoft.com/dotnet/api/system.runtime.compilerservices.overloadresolutionpriorityattribute + [AttributeUsage(AttributeTargets.Constructor | AttributeTargets.Method | AttributeTargets.Property, Inherited = false)] + internal sealed class OverloadResolutionPriorityAttribute(int priority) : Attribute + { + public int Priority => priority; + } +} +#endif + #if !(NETCOREAPP || NETSTANDARD2_1_OR_GREATER) namespace System.Text @@ -31,6 +43,33 @@ public static unsafe int GetBytes(this Encoding encoding, ReadOnlySpan sou } } } + + 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 diff --git a/src/StackExchange.Redis/KeyNotification.cs b/src/StackExchange.Redis/KeyNotification.cs index f563019f3..ca6cc2132 100644 --- a/src/StackExchange.Redis/KeyNotification.cs +++ b/src/StackExchange.Redis/KeyNotification.cs @@ -1,11 +1,15 @@ using System; using System.Buffers.Text; using System.Diagnostics; +using System.Text; using static StackExchange.Redis.KeyNotificationChannels; namespace StackExchange.Redis; /// -/// Represents keyspace and keyevent notifications. +/// Represents keyspace and keyevent notifications, with utility methods for accessing the component data. Additionally, +/// since notifications can be high volume, a range of utility APIs is provided for avoiding allocations, in particular +/// to assist in filtering and inspecting the key without performing string allocations and substring operations. +/// In particular, note that this allows use with the alt-lookup (span-based) APIs on dictionaries. /// public readonly struct KeyNotification { @@ -87,7 +91,7 @@ public int Database /// The key associated with this event. /// /// Note that this will allocate a copy of the key bytes; to avoid allocations, - /// the and APIs can be used. + /// the , , and APIs can be used. public RedisKey GetKey() { if (IsKeySpace) @@ -108,22 +112,56 @@ public RedisKey GetKey() /// /// Get the number of bytes in the key. /// - public int KeyByteCount + public int GetKeyByteCount() { - get + if (IsKeySpace) { - if (IsKeySpace) - { - return ChannelSuffix.Length; - } + return ChannelSuffix.Length; + } - if (IsKeyEvent) - { - return _value.GetByteCount(); - } + if (IsKeyEvent) + { + return _value.GetByteCount(); + } - return 0; + return 0; + } + + /// + /// Get the maximum number of characters in the key, interpreting as UTF8. + /// + public int GetKeyMaxCharCount() + { + if (IsKeySpace) + { + return Encoding.UTF8.GetMaxCharCount(ChannelSuffix.Length); } + + if (IsKeyEvent) + { + return _value.GetMaxCharCount(); + } + + return 0; + } + + /// + /// Get the number of characters in the key, interpreting as UTF8. + /// + /// If a scratch-buffer is required, it may be preferable to use , which is less expensive. + public int GetKeyCharCount() + { + if (IsKeySpace) + { + return Encoding.UTF8.GetCharCount(ChannelSuffix); + } + + if (IsKeyEvent) + { + return _value.GetCharCount(); + } + + return 0; } /// @@ -157,6 +195,35 @@ public bool TryCopyKey(Span destination, out int bytesWritten) return false; } + /// + /// Attempt to copy the bytes from the key to a buffer, returning the number of bytes written. + /// + public bool TryCopyKey(Span destination, out int charsWritten) + { + if (IsKeySpace) + { + var suffix = ChannelSuffix; + if (Encoding.UTF8.GetMaxCharCount(suffix.Length) <= destination.Length || + Encoding.UTF8.GetCharCount(suffix) <= destination.Length) + { + charsWritten = Encoding.UTF8.GetChars(suffix, destination); + return true; + } + } + + if (IsKeyEvent) + { + if (_value.GetMaxCharCount() <= destination.Length || _value.GetCharCount() <= destination.Length) + { + charsWritten = _value.CopyTo(destination); + return true; + } + } + + charsWritten = 0; + return false; + } + /// /// Get the portion of the channel after the "__{keyspace|keyevent}@{db}__:". /// @@ -228,6 +295,25 @@ public bool IsKeyEvent return span.Length >= KeyEventPrefix.Length + MinSuffixBytes && KeyEventPrefix.Is(span.Hash64(), span.Slice(0, KeyEventPrefix.Length)); } } + + /// + /// Indicates whether the key associated with this notification starts with the specified prefix. + /// + /// This API is intended as a high-throughput filter API. + public bool KeyStartsWith(ReadOnlySpan prefix) // intentionally leading people to the BLOB API + { + if (IsKeySpace) + { + return ChannelSuffix.StartsWith(prefix); + } + + if (IsKeyEvent) + { + return _value.StartsWith(prefix); + } + + return false; + } } internal static partial class KeyNotificationChannels diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 871fe71f0..fd148c913 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,15 +1,23 @@ #nullable enable StackExchange.Redis.KeyNotification StackExchange.Redis.KeyNotification.Channel.get -> StackExchange.Redis.RedisChannel +StackExchange.Redis.KeyNotification.GetKeyByteCount() -> int +StackExchange.Redis.KeyNotification.GetKeyCharCount() -> int +StackExchange.Redis.KeyNotification.GetKeyMaxCharCount() -> int +StackExchange.Redis.KeyNotification.KeyStartsWith(System.ReadOnlySpan prefix) -> bool +StackExchange.Redis.KeyNotification.TryCopyKey(System.Span destination, out int charsWritten) -> bool StackExchange.Redis.KeyNotification.Value.get -> StackExchange.Redis.RedisValue StackExchange.Redis.KeyNotification.Database.get -> int StackExchange.Redis.KeyNotification.GetKey() -> StackExchange.Redis.RedisKey StackExchange.Redis.KeyNotification.IsKeyEvent.get -> bool StackExchange.Redis.KeyNotification.IsKeySpace.get -> bool -StackExchange.Redis.KeyNotification.KeyByteCount.get -> int StackExchange.Redis.KeyNotification.KeyNotification() -> void StackExchange.Redis.KeyNotification.TryCopyKey(System.Span destination, out int bytesWritten) -> bool StackExchange.Redis.KeyNotification.Type.get -> StackExchange.Redis.KeyNotificationType +StackExchange.Redis.RedisValue.CopyTo(System.Span destination) -> int +StackExchange.Redis.RedisValue.GetCharCount() -> int +StackExchange.Redis.RedisValue.GetMaxCharCount() -> int +StackExchange.Redis.RedisValue.StartsWith(System.ReadOnlySpan value) -> bool static StackExchange.Redis.KeyNotification.TryParse(in StackExchange.Redis.RedisChannel channel, in StackExchange.Redis.RedisValue value, out StackExchange.Redis.KeyNotification notification) -> bool StackExchange.Redis.ChannelMessage.TryParseKeyNotification(out StackExchange.Redis.KeyNotification notification) -> bool static StackExchange.Redis.RedisChannel.KeyEvent(StackExchange.Redis.KeyNotificationType type, int? database = null) -> StackExchange.Redis.RedisChannel diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index 1f6947460..8dd4c635b 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -869,19 +869,44 @@ private static string ToHex(ReadOnlySpan src) /// /// Gets the length of the value in bytes. /// - public int GetByteCount() + public int GetByteCount() => Type switch { - switch (Type) - { - case StorageType.Null: return 0; - case StorageType.Raw: return _memory.Length; - case StorageType.String: return Encoding.UTF8.GetByteCount((string)_objectOrSentinel!); - case StorageType.Int64: return Format.MeasureInt64(OverlappedValueInt64); - case StorageType.UInt64: return Format.MeasureUInt64(OverlappedValueUInt64); - case StorageType.Double: return Format.MeasureDouble(OverlappedValueDouble); - default: return ThrowUnableToMeasure(); - } - } + StorageType.Null => 0, + StorageType.Raw => _memory.Length, + StorageType.String => Encoding.UTF8.GetByteCount((string)_objectOrSentinel!), + StorageType.Int64 => Format.MeasureInt64(OverlappedValueInt64), + StorageType.UInt64 => Format.MeasureUInt64(OverlappedValueUInt64), + StorageType.Double => Format.MeasureDouble(OverlappedValueDouble), + _ => ThrowUnableToMeasure(), + }; + + /// + /// Gets the length of the value in characters, assuming UTF8 interpretation of BLOB payloads. + /// + public int GetCharCount() => Type switch + { + StorageType.Null => 0, + StorageType.Raw => Encoding.UTF8.GetCharCount(_memory.Span), + StorageType.String => ((string)_objectOrSentinel!).Length, + StorageType.Int64 => Format.MeasureInt64(OverlappedValueInt64), + StorageType.UInt64 => Format.MeasureUInt64(OverlappedValueUInt64), + StorageType.Double => Format.MeasureDouble(OverlappedValueDouble), + _ => ThrowUnableToMeasure(), + }; + + /// + /// Gets the length of the value in characters, assuming UTF8 interpretation of BLOB payloads. + /// + public int GetMaxCharCount() => Type switch + { + StorageType.Null => 0, + StorageType.Raw => Encoding.UTF8.GetMaxCharCount(_memory.Length), + StorageType.String => ((string)_objectOrSentinel!).Length, + StorageType.Int64 => Format.MeasureInt64(OverlappedValueInt64), + StorageType.UInt64 => Format.MeasureUInt64(OverlappedValueUInt64), + StorageType.Double => Format.MeasureDouble(OverlappedValueDouble), + _ => ThrowUnableToMeasure(), + }; private int ThrowUnableToMeasure() => throw new InvalidOperationException("Unable to compute length of type: " + Type); @@ -918,6 +943,33 @@ public int CopyTo(Span destination) } } + /// + /// Copy the value as character data to the provided . + /// + public int CopyTo(Span destination) + { + switch (Type) + { + case StorageType.Null: + return 0; + case StorageType.Raw: + var srcBytes = _memory.Span; + return Encoding.UTF8.GetChars(srcBytes, destination); + case StorageType.String: + var span = ((string)_objectOrSentinel!).AsSpan(); + span.CopyTo(destination); + return span.Length; + case StorageType.Int64: + return Format.FormatInt64(OverlappedValueInt64, destination); + case StorageType.UInt64: + return Format.FormatUInt64(OverlappedValueUInt64, destination); + case StorageType.Double: + return Format.FormatDouble(OverlappedValueDouble, destination); + default: + return ThrowUnableToMeasure(); + } + } + /// /// Converts a to a . /// @@ -1256,5 +1308,50 @@ internal bool TryGetSpan(out ReadOnlySpan span) span = default; return false; } + + /// + /// Indicates whether the current value has the supplied value as a prefix. + /// + /// The to check. + [OverloadResolutionPriority(1)] // prefer this when it is an option (vs casting a byte[] to RedisValue) + public bool StartsWith(ReadOnlySpan value) + { + if (IsNull) return false; + if (value.IsEmpty) return true; + if (IsNullOrEmpty) return false; + + int len; + switch (Type) + { + case StorageType.Raw: + return _memory.Span.StartsWith(value); + case StorageType.Int64: + Span buffer = stackalloc byte[Format.MaxInt64TextLen]; + len = Format.FormatInt64(OverlappedValueInt64, buffer); + return buffer.Slice(0, len).StartsWith(value); + case StorageType.UInt64: + buffer = stackalloc byte[Format.MaxInt64TextLen]; + len = Format.FormatUInt64(OverlappedValueUInt64, buffer); + return buffer.Slice(0, len).StartsWith(value); + case StorageType.Double: + buffer = stackalloc byte[Format.MaxDoubleTextLen]; + len = Format.FormatDouble(OverlappedValueDouble, buffer); + return buffer.Slice(0, len).StartsWith(value); + case StorageType.String: + var s = ((string)_objectOrSentinel!).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); + byte[]? lease = null; + const int MAX_STACK = 128; + buffer = maxBytes <= MAX_STACK ? stackalloc byte[MAX_STACK] : (lease = ArrayPool.Shared.Rent(maxBytes)); + var bytes = Encoding.UTF8.GetBytes(s, buffer); + bool isMatch = buffer.Slice(0, bytes).StartsWith(value); + if (lease is not null) ArrayPool.Shared.Return(lease); + return isMatch; + default: + return false; + } + } } } diff --git a/tests/StackExchange.Redis.Tests/Certificates/CertValidationTests.cs b/tests/StackExchange.Redis.Tests/Certificates/CertValidationTests.cs index a0d9b5c88..fa80114f8 100644 --- a/tests/StackExchange.Redis.Tests/Certificates/CertValidationTests.cs +++ b/tests/StackExchange.Redis.Tests/Certificates/CertValidationTests.cs @@ -51,7 +51,9 @@ public void CheckIssuerValidity() Assert.False(callback(this, endpointCert, null, SslPolicyErrors.RemoteCertificateChainErrors | SslPolicyErrors.RemoteCertificateNotAvailable), "subtest 3f"); } +#pragma warning disable SYSLIB0057 private static X509Certificate2 LoadCert(string certificatePath) => new X509Certificate2(File.ReadAllBytes(certificatePath)); +#pragma warning restore SYSLIB0057 [Fact] public void CheckIssuerArgs() diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs index b9548eb44..d5cfd505c 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs @@ -21,7 +21,7 @@ public void Keyspace_Del_ParsesCorrectly() Assert.Equal(1, notification.Database); Assert.Equal(KeyNotificationType.Del, notification.Type); Assert.Equal("mykey", (string?)notification.GetKey()); - Assert.Equal(5, notification.KeyByteCount); + Assert.Equal(5, notification.GetKeyByteCount()); } [Fact] @@ -38,7 +38,7 @@ public void Keyevent_Del_ParsesCorrectly() Assert.Equal(42, notification.Database); Assert.Equal(KeyNotificationType.Del, notification.Type); Assert.Equal("mykey", (string?)notification.GetKey()); - Assert.Equal(5, notification.KeyByteCount); + Assert.Equal(5, notification.GetKeyByteCount()); } [Fact] @@ -308,7 +308,7 @@ public void DefaultKeyNotification_HasExpectedProperties() Assert.Equal(-1, notification.Database); Assert.Equal(KeyNotificationType.Unknown, notification.Type); Assert.True(notification.GetKey().IsNull); - Assert.Equal(0, notification.KeyByteCount); + Assert.Equal(0, notification.GetKeyByteCount()); Assert.True(notification.Channel.IsNull); Assert.True(notification.Value.IsNull); diff --git a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs index d99c687dc..a75ecbd4b 100644 --- a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -24,12 +26,14 @@ public abstract class PubSubKeyNotificationTests(ITestOutputHelper output, Share private const int DefaultKeyCount = 10; private const int DefaultEventCount = 512; - private RedisKey[] InventKeys(int count = DefaultKeyCount) + private RedisKey[] InventKeys(out byte[] prefix, int count = DefaultKeyCount) { RedisKey[] keys = new RedisKey[count]; + var prefixString = $"{Guid.NewGuid()}/"; + prefix = Encoding.UTF8.GetBytes(prefixString); for (int i = 0; i < count; i++) { - keys[i] = Guid.NewGuid().ToString(); + keys[i] = $"{prefixString}{Guid.NewGuid()}"; } return keys; } @@ -92,31 +96,67 @@ private static int GetConnectedCount(IConnectionMultiplexer muxer, in RedisChann => muxer is ConnectionMultiplexer typed && typed.TryGetSubscription(channel, out var sub) ? sub.GetConnectionCount() : 1; + private sealed class Counter + { + private int _count; + public int Count => Volatile.Read(ref _count); + public int Increment() => Interlocked.Increment(ref _count); + } + [Fact] public async Task KeyEvent_CanObserveSimple_ViaCallbackHandler() { await using var conn = Create(); var db = conn.GetDatabase(); - var keys = InventKeys(); + var keys = InventKeys(out var prefix); var channel = RedisChannel.KeyEvent(KeyNotificationType.SAdd); var sub = conn.GetSubscriber(); await sub.UnsubscribeAsync(channel); - HashSet observedKeys = []; int count = 0, callbackCount = 0; TaskCompletionSource allDone = new(); + + ConcurrentDictionary observedCounts = new(); + foreach (var key in keys) + { + observedCounts[key.ToString()] = new(); + } +#if NET9_0_OR_GREATER + // demonstrate that we can use the alt-lookup APIs to avoid string allocations + var altLookup = observedCounts.GetAlternateLookup>(); + static Counter? FindViaAltLookup( + in KeyNotification notification, + ConcurrentDictionary.AlternateLookup> lookup) + { + Span scratch = stackalloc char[1024]; + notification.TryCopyKey(scratch, out var bytesWritten); + return lookup.TryGetValue(scratch.Slice(0, bytesWritten), out var counter) + ? counter + : null; + } +#endif + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => { Interlocked.Increment(ref callbackCount); if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) && notification is { IsKeyEvent: true, Type: KeyNotificationType.SAdd }) { - var recvKey = notification.GetKey(); - lock (observedKeys) + if (notification.KeyStartsWith(prefix)) // avoid problems with parallel SADD tests { - int currentCount = ++count; - var newKey = observedKeys.Add(recvKey); - if (newKey) + int currentCount = Interlocked.Increment(ref count); + + // get the key and check that we expected it + var recvKey = notification.GetKey(); + Assert.True(observedCounts.TryGetValue(recvKey.ToString(), out var counter)); + +#if NET9_0_OR_GREATER + var viaAlt = FindViaAltLookup(notification, altLookup); + Assert.Same(counter, viaAlt); +#endif + + // accounting... + if (counter.Increment() == 1) { Log($"Observed key: '{recvKey}' after {currentCount} events"); } @@ -131,12 +171,17 @@ await sub.SubscribeAsync(channel, (recvChannel, recvValue) => await Task.Delay(300).ForAwait(); // give it a moment to settle - HashSet sentKeys = new(keys.Length); + Dictionary sentCounts = new(keys.Length); + foreach (var key in keys) + { + sentCounts[key] = new(); + } + for (int i = 0; i < DefaultEventCount; i++) { var key = SelectKey(keys); + sentCounts[key].Increment(); await db.SetAddAsync(key, i); - sentKeys.Add(key); // just in case Random has a bad day (obvious Dilbert link is obvious) } // Wait for all events to be observed @@ -150,13 +195,9 @@ await sub.SubscribeAsync(channel, (recvChannel, recvValue) => throw new TimeoutException($"Timeout; {Volatile.Read(ref callbackCount)} events observed", ex); } - lock (observedKeys) + foreach (var key in keys) { - Assert.Equal(sentKeys.Count, observedKeys.Count); - foreach (var key in sentKeys) - { - Assert.Contains(key, observedKeys); - } + Assert.Equal(sentCounts[key].Count, observedCounts[key.ToString()].Count); } } } diff --git a/tests/StackExchange.Redis.Tests/SSLTests.cs b/tests/StackExchange.Redis.Tests/SSLTests.cs index 0dafe3f9b..c9c5cc2bb 100644 --- a/tests/StackExchange.Redis.Tests/SSLTests.cs +++ b/tests/StackExchange.Redis.Tests/SSLTests.cs @@ -240,7 +240,9 @@ public async Task RedisLabsSSL() Skip.IfNoConfig(nameof(TestConfig.Config.RedisLabsSslServer), TestConfig.Current.RedisLabsSslServer); Skip.IfNoConfig(nameof(TestConfig.Config.RedisLabsPfxPath), TestConfig.Current.RedisLabsPfxPath); +#pragma warning disable SYSLIB0057 var cert = new X509Certificate2(TestConfig.Current.RedisLabsPfxPath, ""); +#pragma warning restore SYSLIB0057 Assert.NotNull(cert); Log("Thumbprint: " + cert.Thumbprint); diff --git a/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj b/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj index f6e38236b..f09780f7a 100644 --- a/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj +++ b/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj @@ -1,6 +1,6 @@  - net481;net8.0 + net481;net8.0;net10.0 Exe StackExchange.Redis.Tests true diff --git a/tests/StackExchange.Redis.Tests/SyncContextTests.cs b/tests/StackExchange.Redis.Tests/SyncContextTests.cs index b98caefeb..5feb37e3d 100644 --- a/tests/StackExchange.Redis.Tests/SyncContextTests.cs +++ b/tests/StackExchange.Redis.Tests/SyncContextTests.cs @@ -122,7 +122,7 @@ public MySyncContext(TextWriter log) private int _opCount; private void Incr() => Interlocked.Increment(ref _opCount); - public void Reset() => Thread.VolatileWrite(ref _opCount, 0); + public void Reset() => Volatile.Write(ref _opCount, 0); public override string ToString() => $"Sync context ({(IsCurrent ? "active" : "inactive")}): {OpCount}"; From c3cf8d770e64348c8972bcf8ce22e172c0a16472 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 29 Jan 2026 12:13:56 +0000 Subject: [PATCH 17/37] improve alt-lookup logic --- .../PubSubKeyNotificationTests.cs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs index a75ecbd4b..a8f02e4af 100644 --- a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; @@ -121,6 +122,7 @@ public async Task KeyEvent_CanObserveSimple_ViaCallbackHandler() { observedCounts[key.ToString()] = new(); } + #if NET9_0_OR_GREATER // demonstrate that we can use the alt-lookup APIs to avoid string allocations var altLookup = observedCounts.GetAlternateLookup>(); @@ -128,11 +130,18 @@ public async Task KeyEvent_CanObserveSimple_ViaCallbackHandler() in KeyNotification notification, ConcurrentDictionary.AlternateLookup> lookup) { - Span scratch = stackalloc char[1024]; - notification.TryCopyKey(scratch, out var bytesWritten); - return lookup.TryGetValue(scratch.Slice(0, bytesWritten), out var counter) - ? counter - : null; + // Demonstrate typical alt-lookup usage; this is an advanced topic, so it + // isn't trivial to grok, but: this is typical of perf-focused APIs. + char[]? lease = null; + const int MAX_STACK = 128; + var maxLength = notification.GetKeyMaxCharCount(); + Span scratch = maxLength <= MAX_STACK + ? stackalloc char[MAX_STACK] + : (lease = ArrayPool.Shared.Rent(maxLength)); + Assert.True(notification.TryCopyKey(scratch, out var length)); + if (!lookup.TryGetValue(scratch.Slice(0, length), out var counter)) counter = null; + if (lease is not null) ArrayPool.Shared.Return(lease); + return counter; } #endif From e8cc903542771c823ef2603417d046430ca7a62b Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 29 Jan 2026 13:24:14 +0000 Subject: [PATCH 18/37] Bump tests (and CI etc) to net10, to allow up-to-date bits --- .github/workflows/CI.yml | 6 +++--- Directory.Build.props | 2 +- Directory.Packages.props | 3 ++- src/StackExchange.Redis/KeyNotification.cs | 18 +++++++++++------- .../PublicAPI/PublicAPI.Unshipped.txt | 6 +++--- .../StackExchange.Redis.csproj | 5 ++++- tests/StackExchange.Redis.Tests/HashTests.cs | 2 +- .../KeyNotificationTests.cs | 4 ++-- .../RedisValueEquivalencyTests.cs | 16 +++++++++++----- 9 files changed, 38 insertions(+), 24 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 51f96b88d..2a92af544 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -22,14 +22,14 @@ jobs: fetch-depth: 0 # Fetch the full history - name: Start Redis Services (docker-compose) working-directory: ./tests/RedisConfigs - run: docker compose -f docker-compose.yml up -d --wait + run: docker compose -f docker-compose.yml up -d --wait - name: Install .NET SDK uses: actions/setup-dotnet@v3 with: - dotnet-version: | + dotnet-version: | 6.0.x 8.0.x - 9.0.x + 10.0.x - name: .NET Build run: dotnet build Build.csproj -c Release /p:CI=true - name: StackExchange.Redis.Tests diff --git a/Directory.Build.props b/Directory.Build.props index 9f10eddcd..06542aa32 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -15,7 +15,7 @@ https://stackexchange.github.io/StackExchange.Redis/ MIT - 13 + 14 git https://github.com/StackExchange/StackExchange.Redis/ diff --git a/Directory.Packages.props b/Directory.Packages.props index 2088a054f..3fa9e0e3d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,8 @@ - + + diff --git a/src/StackExchange.Redis/KeyNotification.cs b/src/StackExchange.Redis/KeyNotification.cs index ca6cc2132..9124d1a6a 100644 --- a/src/StackExchange.Redis/KeyNotification.cs +++ b/src/StackExchange.Redis/KeyNotification.cs @@ -11,12 +11,16 @@ namespace StackExchange.Redis; /// to assist in filtering and inspecting the key without performing string allocations and substring operations. /// In particular, note that this allows use with the alt-lookup (span-based) APIs on dictionaries. /// -public readonly struct KeyNotification +public readonly ref struct KeyNotification { + // this type has been designed with the intent of being able to move the entire thing alloc-free in some future + // high-throughput callback, potentially with a ReadOnlySpan field for the key fragment; this is + // not implemented currently, but is why this is a ref struct + /// /// If the channel is either a keyspace or keyevent notification, parsed the data. /// - public static bool TryParse(in RedisChannel channel, in RedisValue value, out KeyNotification notification) + public static bool TryParse(scoped in RedisChannel channel, scoped in RedisValue value, out KeyNotification notification) { // validate that it looks reasonable var span = channel.Span; @@ -51,18 +55,18 @@ public static bool TryParse(in RedisChannel channel, in RedisValue value, out Ke /// /// The channel associated with this notification. /// - public RedisChannel Channel => _channel; + public RedisChannel GetChannel() => _channel; /// /// The payload associated with this notification. /// - public RedisValue Value => _value; + public RedisValue GetValue() => _value; // effectively we just wrap a channel, but: we've pre-validated that things make sense private readonly RedisChannel _channel; private readonly RedisValue _value; - internal KeyNotification(in RedisChannel channel, in RedisValue value) + internal KeyNotification(scoped in RedisChannel channel, scoped in RedisValue value) { _channel = channel; _value = value; @@ -103,7 +107,7 @@ public RedisKey GetKey() if (IsKeyEvent) { // then the channel contains the event-type, and the payload contains the key - return (byte[]?)Value; // todo: this could probably side-step + return (byte[]?)_value; // todo: this could probably side-step } return RedisKey.Null; @@ -240,7 +244,7 @@ private ReadOnlySpan ChannelSuffix /// /// The type of notification associated with this event, if it is well-known - otherwise . /// - /// Unexpected values can be processed manually from the and . + /// Unexpected values can be processed manually from the and . public KeyNotificationType Type { get diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index fd148c913..d5880ac61 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,12 +1,12 @@ #nullable enable StackExchange.Redis.KeyNotification -StackExchange.Redis.KeyNotification.Channel.get -> StackExchange.Redis.RedisChannel +StackExchange.Redis.KeyNotification.GetChannel() -> StackExchange.Redis.RedisChannel StackExchange.Redis.KeyNotification.GetKeyByteCount() -> int StackExchange.Redis.KeyNotification.GetKeyCharCount() -> int StackExchange.Redis.KeyNotification.GetKeyMaxCharCount() -> int +StackExchange.Redis.KeyNotification.GetValue() -> StackExchange.Redis.RedisValue StackExchange.Redis.KeyNotification.KeyStartsWith(System.ReadOnlySpan prefix) -> bool StackExchange.Redis.KeyNotification.TryCopyKey(System.Span destination, out int charsWritten) -> bool -StackExchange.Redis.KeyNotification.Value.get -> StackExchange.Redis.RedisValue StackExchange.Redis.KeyNotification.Database.get -> int StackExchange.Redis.KeyNotification.GetKey() -> StackExchange.Redis.RedisKey StackExchange.Redis.KeyNotification.IsKeyEvent.get -> bool @@ -18,7 +18,7 @@ StackExchange.Redis.RedisValue.CopyTo(System.Span destination) -> int StackExchange.Redis.RedisValue.GetCharCount() -> int StackExchange.Redis.RedisValue.GetMaxCharCount() -> int StackExchange.Redis.RedisValue.StartsWith(System.ReadOnlySpan value) -> bool -static StackExchange.Redis.KeyNotification.TryParse(in StackExchange.Redis.RedisChannel channel, in StackExchange.Redis.RedisValue value, out StackExchange.Redis.KeyNotification notification) -> bool +static StackExchange.Redis.KeyNotification.TryParse(scoped in StackExchange.Redis.RedisChannel channel, scoped in StackExchange.Redis.RedisValue value, out StackExchange.Redis.KeyNotification notification) -> bool StackExchange.Redis.ChannelMessage.TryParseKeyNotification(out StackExchange.Redis.KeyNotification notification) -> bool static StackExchange.Redis.RedisChannel.KeyEvent(StackExchange.Redis.KeyNotificationType type, int? database = null) -> StackExchange.Redis.RedisChannel static StackExchange.Redis.RedisChannel.KeyEvent(System.ReadOnlySpan type, int? database) -> StackExchange.Redis.RedisChannel diff --git a/src/StackExchange.Redis/StackExchange.Redis.csproj b/src/StackExchange.Redis/StackExchange.Redis.csproj index 983624bc0..84e495f1a 100644 --- a/src/StackExchange.Redis/StackExchange.Redis.csproj +++ b/src/StackExchange.Redis/StackExchange.Redis.csproj @@ -19,7 +19,10 @@ - + + + + diff --git a/tests/StackExchange.Redis.Tests/HashTests.cs b/tests/StackExchange.Redis.Tests/HashTests.cs index af2fa11c8..9523ca102 100644 --- a/tests/StackExchange.Redis.Tests/HashTests.cs +++ b/tests/StackExchange.Redis.Tests/HashTests.cs @@ -265,7 +265,7 @@ public async Task TestGetAll() } var inRedis = (await db.HashGetAllAsync(key).ForAwait()).ToDictionary( - x => Guid.Parse(x.Name!), x => int.Parse(x.Value!)); + x => Guid.Parse((string)x.Name!), x => int.Parse(x.Value!)); Assert.Equal(shouldMatch.Count, inRedis.Count); diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs index d5cfd505c..aa18e720d 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs @@ -309,8 +309,8 @@ public void DefaultKeyNotification_HasExpectedProperties() Assert.Equal(KeyNotificationType.Unknown, notification.Type); Assert.True(notification.GetKey().IsNull); Assert.Equal(0, notification.GetKeyByteCount()); - Assert.True(notification.Channel.IsNull); - Assert.True(notification.Value.IsNull); + Assert.True(notification.GetChannel().IsNull); + Assert.True(notification.GetValue().IsNull); // TryCopyKey should return false and write 0 bytes Span buffer = stackalloc byte[10]; diff --git a/tests/StackExchange.Redis.Tests/RedisValueEquivalencyTests.cs b/tests/StackExchange.Redis.Tests/RedisValueEquivalencyTests.cs index 7f6ad1561..391a0237a 100644 --- a/tests/StackExchange.Redis.Tests/RedisValueEquivalencyTests.cs +++ b/tests/StackExchange.Redis.Tests/RedisValueEquivalencyTests.cs @@ -297,11 +297,17 @@ public void RedisValueStartsWith() Assert.False(x.StartsWith(123), LineNumber()); Assert.False(x.StartsWith(false), LineNumber()); - Assert.True(x.StartsWith(Encoding.ASCII.GetBytes("a")), LineNumber()); - Assert.True(x.StartsWith(Encoding.ASCII.GetBytes("ab")), LineNumber()); - Assert.True(x.StartsWith(Encoding.ASCII.GetBytes("abc")), LineNumber()); - Assert.False(x.StartsWith(Encoding.ASCII.GetBytes("abd")), LineNumber()); - Assert.False(x.StartsWith(Encoding.ASCII.GetBytes("abcd")), LineNumber()); + Assert.True(x.StartsWith((RedisValue)Encoding.ASCII.GetBytes("a")), LineNumber()); + Assert.True(x.StartsWith((RedisValue)Encoding.ASCII.GetBytes("ab")), LineNumber()); + Assert.True(x.StartsWith((RedisValue)Encoding.ASCII.GetBytes("abc")), LineNumber()); + Assert.False(x.StartsWith((RedisValue)Encoding.ASCII.GetBytes("abd")), LineNumber()); + Assert.False(x.StartsWith((RedisValue)Encoding.ASCII.GetBytes("abcd")), LineNumber()); + + Assert.True(x.StartsWith("a"u8), LineNumber()); + Assert.True(x.StartsWith("ab"u8), LineNumber()); + Assert.True(x.StartsWith("abc"u8), LineNumber()); + Assert.False(x.StartsWith("abd"u8), LineNumber()); + Assert.False(x.StartsWith("abcd"u8), LineNumber()); x = 10; // integers are effectively strings in this context Assert.True(x.StartsWith(1), LineNumber()); From ee6515f20666b070d9a7272968e0291e80f6dfc3 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 29 Jan 2026 14:14:45 +0000 Subject: [PATCH 19/37] queue vs handler tests --- .../PublicAPI/PublicAPI.Unshipped.txt | 1 + src/StackExchange.Redis/RedisChannel.cs | 47 +++- .../PubSubKeyNotificationTests.cs | 239 ++++++++++++++---- 3 files changed, 226 insertions(+), 61 deletions(-) diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index d5880ac61..fcf301471 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -80,3 +80,4 @@ StackExchange.Redis.KeyNotificationType.ZRem = 49 -> StackExchange.Redis.KeyNoti StackExchange.Redis.KeyNotificationType.ZRemByRank = 47 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.ZRemByScore = 48 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.ZUnionStore = 45 -> StackExchange.Redis.KeyNotificationType +static StackExchange.Redis.RedisChannel.KeySpacePrefix(System.ReadOnlySpan prefix, int? database = null) -> StackExchange.Redis.RedisChannel diff --git a/src/StackExchange.Redis/RedisChannel.cs b/src/StackExchange.Redis/RedisChannel.cs index 7b208bb9d..b4310431f 100644 --- a/src/StackExchange.Redis/RedisChannel.cs +++ b/src/StackExchange.Redis/RedisChannel.cs @@ -185,6 +185,16 @@ public static RedisChannel KeySpace(in RedisKey key, int database) public static RedisChannel KeySpacePattern(in RedisKey pattern, int? database = null) => BuildKeySpace(pattern, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode); + /// + /// Create a key-notification channel using a raw prefix, optionally in a specified database. + /// + public static RedisChannel KeySpacePrefix(ReadOnlySpan prefix, int? database = null) + { + if (prefix.IsEmpty) Throw(); + return BuildKeySpace(RedisKey.Null, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode, prefix); + static void Throw() => throw new ArgumentNullException(nameof(prefix)); + } + private const int DatabaseScratchBufferSize = 16; // largest non-negative int32 is 10 digits private static ReadOnlySpan AppendDatabase(Span target, int? database, RedisChannelOptions options) @@ -241,18 +251,25 @@ private static Span AppendAndAdvance(Span target, scoped ReadOnlySpa return target.Slice(value.Length); } - private static RedisChannel BuildKeySpace(in RedisKey key, int? database, RedisChannelOptions options) + private static RedisChannel BuildKeySpace(in RedisKey key, int? database, RedisChannelOptions options, ReadOnlySpan prefix = default) { int keyLen; - if (key.IsNull) + if (prefix.IsEmpty) { - if ((options & RedisChannelOptions.Pattern) == 0) throw new ArgumentNullException(nameof(key)); - keyLen = 1; + if (key.IsNull) + { + if ((options & RedisChannelOptions.Pattern) == 0) throw new ArgumentNullException(nameof(key)); + keyLen = 1; + } + else + { + keyLen = key.TotalLength(); + if (keyLen == 0) throw new ArgumentOutOfRangeException(nameof(key)); + } } else { - keyLen = key.TotalLength(); - if (keyLen == 0) throw new ArgumentOutOfRangeException(nameof(key)); + keyLen = prefix.Length + 1; // allow for the * } var db = AppendDatabase(stackalloc byte[DatabaseScratchBufferSize], database, options); @@ -264,15 +281,25 @@ private static RedisChannel BuildKeySpace(in RedisKey key, int? database, RedisC target = AppendAndAdvance(target, db); target = AppendAndAdvance(target, "__:"u8); Debug.Assert(keyLen == target.Length); // should have exactly "len" bytes remaining - if (key.IsNull) + if (prefix.IsEmpty) { - target[0] = (byte)'*'; - target = target.Slice(1); + if (key.IsNull) + { + target[0] = (byte)'*'; + target = target.Slice(1); + } + else + { + target = target.Slice(key.CopyTo(target)); + } } else { - target = target.Slice(key.CopyTo(target)); + prefix.CopyTo(target); + target[prefix.Length] = (byte)'*'; + target = target.Slice(prefix.Length + 1); } + Debug.Assert(target.IsEmpty); // should have calculated length correctly return new RedisChannel(arr, options); } diff --git a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs index a8f02e4af..b2149d56a 100644 --- a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -10,22 +11,25 @@ namespace StackExchange.Redis.Tests; -public sealed class PubSubKeyNotificationTestsCluster(ITestOutputHelper output, SharedConnectionFixture fixture) - : PubSubKeyNotificationTests(output, fixture) +// ReSharper disable once UnusedMember.Global - used via test framework +public sealed class PubSubKeyNotificationTestsCluster(ITestOutputHelper output, ITestContextAccessor context, SharedConnectionFixture fixture) + : PubSubKeyNotificationTests(output, context, fixture) { protected override string GetConfiguration() => TestConfig.Current.ClusterServersAndPorts; } -public sealed class PubSubKeyNotificationTestsStandalone(ITestOutputHelper output, SharedConnectionFixture fixture) - : PubSubKeyNotificationTests(output, fixture) +// ReSharper disable once UnusedMember.Global - used via test framework +public sealed class PubSubKeyNotificationTestsStandalone(ITestOutputHelper output, ITestContextAccessor context, SharedConnectionFixture fixture) + : PubSubKeyNotificationTests(output, context, fixture) { } -public abstract class PubSubKeyNotificationTests(ITestOutputHelper output, SharedConnectionFixture? fixture = null) +public abstract class PubSubKeyNotificationTests(ITestOutputHelper output, ITestContextAccessor context, SharedConnectionFixture? fixture = null) : TestBase(output, fixture) { private const int DefaultKeyCount = 10; private const int DefaultEventCount = 512; + private CancellationToken CancellationToken => context.Current.CancellationToken; private RedisKey[] InventKeys(out byte[] prefix, int count = DefaultKeyCount) { @@ -76,6 +80,7 @@ public async Task KeySpace_CanSubscribe_ManualPublish() var db = conn.GetDatabase(); var channel = RedisChannel.KeyEvent("nonesuch"u8, database: null); + Log($"Monitoring channel: {channel}"); var sub = conn.GetSubscriber(); await sub.UnsubscribeAsync(channel); @@ -111,10 +116,11 @@ public async Task KeyEvent_CanObserveSimple_ViaCallbackHandler() var db = conn.GetDatabase(); var keys = InventKeys(out var prefix); - var channel = RedisChannel.KeyEvent(KeyNotificationType.SAdd); + var channel = RedisChannel.KeyEvent(KeyNotificationType.SAdd, db.Database); + Log($"Monitoring channel: {channel}"); var sub = conn.GetSubscriber(); await sub.UnsubscribeAsync(channel); - int count = 0, callbackCount = 0; + Counter callbackCount = new(), matchingEventCount = new(); TaskCompletionSource allDone = new(); ConcurrentDictionary observedCounts = new(); @@ -123,61 +129,172 @@ public async Task KeyEvent_CanObserveSimple_ViaCallbackHandler() observedCounts[key.ToString()] = new(); } -#if NET9_0_OR_GREATER - // demonstrate that we can use the alt-lookup APIs to avoid string allocations - var altLookup = observedCounts.GetAlternateLookup>(); - static Counter? FindViaAltLookup( - in KeyNotification notification, - ConcurrentDictionary.AlternateLookup> lookup) - { - // Demonstrate typical alt-lookup usage; this is an advanced topic, so it - // isn't trivial to grok, but: this is typical of perf-focused APIs. - char[]? lease = null; - const int MAX_STACK = 128; - var maxLength = notification.GetKeyMaxCharCount(); - Span scratch = maxLength <= MAX_STACK - ? stackalloc char[MAX_STACK] - : (lease = ArrayPool.Shared.Rent(maxLength)); - Assert.True(notification.TryCopyKey(scratch, out var length)); - if (!lookup.TryGetValue(scratch.Slice(0, length), out var counter)) counter = null; - if (lease is not null) ArrayPool.Shared.Return(lease); - return counter; - } -#endif - await sub.SubscribeAsync(channel, (recvChannel, recvValue) => { - Interlocked.Increment(ref callbackCount); + callbackCount.Increment(); if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) && notification is { IsKeyEvent: true, Type: KeyNotificationType.SAdd }) { - if (notification.KeyStartsWith(prefix)) // avoid problems with parallel SADD tests + OnNotification(notification, prefix, matchingEventCount, observedCounts, allDone); + } + }); + + await SendAndObserveAsync(keys, db, allDone, callbackCount, observedCounts); + await sub.UnsubscribeAsync(channel); + } + + [Fact] + public async Task KeyEvent_CanObserveSimple_ViaQueue() + { + await using var conn = Create(); + var db = conn.GetDatabase(); + + var keys = InventKeys(out var prefix); + var channel = RedisChannel.KeyEvent(KeyNotificationType.SAdd, db.Database); + Log($"Monitoring channel: {channel}"); + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + Counter callbackCount = new(), matchingEventCount = new(); + TaskCompletionSource allDone = new(); + + ConcurrentDictionary observedCounts = new(); + foreach (var key in keys) + { + observedCounts[key.ToString()] = new(); + } + + var queue = await sub.SubscribeAsync(channel); + _ = Task.Run(async () => + { + await foreach (var msg in queue.WithCancellation(CancellationToken)) + { + callbackCount.Increment(); + if (msg.TryParseKeyNotification(out var notification) + && notification is { IsKeyEvent: true, Type: KeyNotificationType.SAdd }) { - int currentCount = Interlocked.Increment(ref count); + OnNotification(notification, prefix, matchingEventCount, observedCounts, allDone); + } + } + }); - // get the key and check that we expected it - var recvKey = notification.GetKey(); - Assert.True(observedCounts.TryGetValue(recvKey.ToString(), out var counter)); + await SendAndObserveAsync(keys, db, allDone, callbackCount, observedCounts); + await queue.UnsubscribeAsync(); + } -#if NET9_0_OR_GREATER - var viaAlt = FindViaAltLookup(notification, altLookup); - Assert.Same(counter, viaAlt); -#endif + [Fact] + public async Task KeyNotification_CanObserveSimple_ViaCallbackHandler() + { + await using var conn = Create(); + var db = conn.GetDatabase(); - // accounting... - if (counter.Increment() == 1) - { - Log($"Observed key: '{recvKey}' after {currentCount} events"); - } + var keys = InventKeys(out var prefix); + var channel = RedisChannel.KeySpacePrefix(prefix, db.Database); + Log($"Monitoring channel: {channel}"); + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + Counter callbackCount = new(), matchingEventCount = new(); + TaskCompletionSource allDone = new(); + + ConcurrentDictionary observedCounts = new(); + foreach (var key in keys) + { + observedCounts[key.ToString()] = new(); + } - if (currentCount == DefaultEventCount) - { - allDone.TrySetResult(true); - } + var queue = await sub.SubscribeAsync(channel); + _ = Task.Run(async () => + { + await foreach (var msg in queue.WithCancellation(CancellationToken)) + { + callbackCount.Increment(); + if (msg.TryParseKeyNotification(out var notification) + && notification is { IsKeySpace: true, Type: KeyNotificationType.SAdd }) + { + OnNotification(notification, prefix, matchingEventCount, observedCounts, allDone); } } }); + await SendAndObserveAsync(keys, db, allDone, callbackCount, observedCounts); + await sub.UnsubscribeAsync(channel); + } + + [Fact] + public async Task KeyNotification_CanObserveSimple_ViaQueue() + { + await using var conn = Create(); + var db = conn.GetDatabase(); + + var keys = InventKeys(out var prefix); + var channel = RedisChannel.KeySpacePrefix(prefix, db.Database); + Log($"Monitoring channel: {channel}"); + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + Counter callbackCount = new(), matchingEventCount = new(); + TaskCompletionSource allDone = new(); + + ConcurrentDictionary observedCounts = new(); + foreach (var key in keys) + { + observedCounts[key.ToString()] = new(); + } + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + callbackCount.Increment(); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification is { IsKeySpace: true, Type: KeyNotificationType.SAdd }) + { + OnNotification(notification, prefix, matchingEventCount, observedCounts, allDone); + } + }); + + await SendAndObserveAsync(keys, db, allDone, callbackCount, observedCounts); + await sub.UnsubscribeAsync(channel); + } + + private void OnNotification( + in KeyNotification notification, + ReadOnlySpan prefix, + Counter matchingEventCount, + ConcurrentDictionary observedCounts, + TaskCompletionSource allDone) + { + if (notification.KeyStartsWith(prefix)) // avoid problems with parallel SADD tests + { + int currentCount = matchingEventCount.Increment(); + + // get the key and check that we expected it + var recvKey = notification.GetKey(); + Assert.True(observedCounts.TryGetValue(recvKey.ToString(), out var counter)); + +#if NET9_0_OR_GREATER + // it would be more efficient to stash the alt-lookup, but that would make our API here non-viable, + // since we need to support multiple frameworks + var viaAlt = FindViaAltLookup(notification, observedCounts.GetAlternateLookup>()); + Assert.Same(counter, viaAlt); +#endif + + // accounting... + if (counter.Increment() == 1) + { + Log($"Observed key: '{recvKey}' after {currentCount} events"); + } + + if (currentCount == DefaultEventCount) + { + allDone.TrySetResult(true); + } + } + } + + private async Task SendAndObserveAsync( + RedisKey[] keys, + IDatabase db, + TaskCompletionSource allDone, + Counter callbackCount, + ConcurrentDictionary observedCounts) + { await Task.Delay(300).ForAwait(); // give it a moment to settle Dictionary sentCounts = new(keys.Length); @@ -198,10 +315,9 @@ await sub.SubscribeAsync(channel, (recvChannel, recvValue) => { Assert.True(await allDone.Task.WithTimeout(5000)); } - catch (TimeoutException ex) + catch (TimeoutException) when (callbackCount.Count == 0) { - // if this is zero, the real problem is probably ala KeySpace_Events_Enabled - throw new TimeoutException($"Timeout; {Volatile.Read(ref callbackCount)} events observed", ex); + Assert.Fail($"Timeout with zero events; are keyspace events enabled?"); } foreach (var key in keys) @@ -209,4 +325,25 @@ await sub.SubscribeAsync(channel, (recvChannel, recvValue) => Assert.Equal(sentCounts[key].Count, observedCounts[key.ToString()].Count); } } + +#if NET9_0_OR_GREATER + // demonstrate that we can use the alt-lookup APIs to avoid string allocations + private static Counter? FindViaAltLookup( + in KeyNotification notification, + ConcurrentDictionary.AlternateLookup> lookup) + { + // Demonstrate typical alt-lookup usage; this is an advanced topic, so it + // isn't trivial to grok, but: this is typical of perf-focused APIs. + char[]? lease = null; + const int MAX_STACK = 128; + var maxLength = notification.GetKeyMaxCharCount(); + Span scratch = maxLength <= MAX_STACK + ? stackalloc char[MAX_STACK] + : (lease = ArrayPool.Shared.Rent(maxLength)); + Assert.True(notification.TryCopyKey(scratch, out var length)); + if (!lookup.TryGetValue(scratch.Slice(0, length), out var counter)) counter = null; + if (lease is not null) ArrayPool.Shared.Return(lease); + return counter; + } +#endif } From 661d8f460e4cb52c7861d358a8e095bfaf5ff61c Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 29 Jan 2026 16:21:50 +0000 Subject: [PATCH 20/37] docs; moar tests --- docs/KeyspaceNotifications.md | 93 +++++++++++++++++++ src/StackExchange.Redis/KeyNotification.cs | 54 +++++++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 5 +- src/StackExchange.Redis/RedisValue.cs | 26 ++++-- .../KeyNotificationTests.cs | 71 ++++++++++++++ 5 files changed, 240 insertions(+), 9 deletions(-) create mode 100644 docs/KeyspaceNotifications.md diff --git a/docs/KeyspaceNotifications.md b/docs/KeyspaceNotifications.md new file mode 100644 index 000000000..2a8f97507 --- /dev/null +++ b/docs/KeyspaceNotifications.md @@ -0,0 +1,93 @@ +# Redis Keyspace Notifications + +Redis keyspace notifications let you monitor operations happening on your Redis keys in real-time. StackExchange.Redis provides a strongly-typed API for subscribing to and consuming these events. +This could be used for example to implement a cache invalidation strategy. + +## Prerequisites + +### Redis Configuration + +You must [enable keyspace notifications](https://redis.io/docs/latest/develop/pubsub/keyspace-notifications/#configuration) in your Redis server config, +for example: + +``` conf +notify-keyspace-events AKE +``` + +- **A** - All event types +- **K** - Keyspace notifications (`__keyspace@__:`) +- **E** - Keyevent notifications (`__keyevent@__:`) + +The two types of event (keyspace and keyevent) encode the same information, but in different formats. +To simplify consumption, StackExchange.Redis provides a unified API for both types of event, via the `KeyNotification` type. + +### Event Broadcasting in Redis Cluster + +Importantly, in Redis Cluster, keyspace notifications are **not** broadcast to all nodes - they are only received by clients connecting to the +individual node where the keyspace notification originated, i.e. where the key was modified. +This is different to how regular pub/sub events are handled, where a subscription to a channel on one node will receive events published on any node. +Clients must explicitly subscribe to the same channel on each node they wish to receive events from, which typically means: every primary node in the cluster. +To make this easier, StackExchange.Redis provides dedicated APIs for subscribing to keyspace and keyevent notifications that handle this for you. + +## Quick Start + +As an example, we'll subscribe to all keys with a specific prefix, and print out the key and event type for each notification. First, +we need to create a `RedisChannel`: + +```csharp +// this will subscribe to __keyspace@0__:user:*, including supporting Redis Cluster +var channel = RedisChannel.KeySpacePrefix(prefix: "user:"u8, database: 0); +``` + +Note that there are a range of other `KeySpace...` and `KeyEvent...` methods for different scenarios. + +Next, we subscribe to the channel and process the notifications; there are two main approaches: callback-based and queue-based. + +Queue-based: + +```csharp +var queue = await sub.SubscribeAsync(channel); +_ = Task.Run(async () => +{ + await foreach (var msg in queue) + { + if (msg.TryParseKeyNotification(out var notification)) + { + Console.WriteLine($"Key: {notification.GetKey()}"); + Console.WriteLine($"Type: {notification.Type}"); + Console.WriteLine($"Database: {notification.Database}"); + } + } +}); +``` + +Callback-based: + +```csharp +sub.Subscribe(channel, (recvChannel, recvValue) => +{ + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification)) + { + Console.WriteLine($"Key: {notification.GetKey()}"); + Console.WriteLine($"Type: {notification.Type}"); + Console.WriteLine($"Database: {notification.Database}"); + } +}); +``` + +## Performance considerations for KeyNotification + +The `KeyNotification` struct provides parsed notification data, including (as already shown) the key, event type, +database, etc. Note that using `GetKey()` will allocate a copy of the key bytes; to avoid allocations, +you can use `TryCopyKey()` to copy the key bytes into a provided buffer (potentially with `GetKeyByteCount()`, +`GetKeyMaxCharCount()`, etc in order to size the buffer appropriately). Similarly, `KeyStartsWith()` can be used to +efficiently check the key prefix without allocating a string. This approach is designed to be efficient for high-volume +notification processing, and in particular: for use with the alt-lookup (span) APIs that are slowly being introduced +in various .NET APIs. + +For example, with a `ConcurrentDictionary` (for some `T`), you can use `GetAlternateLookup>()` +to get an alternate lookup API that takes a `ReadOnlySpan` instead of a `string`, and then use `TryCopyKey()` to copy +the key bytes into a buffer, and then use the alt-lookup API to find the value. This means that we avoid allocating a string +for the key entirely, and instead just copy the bytes into a buffer. If we consider that commonly a local cache will *not* +contain the key for the majority of notifications (since they are for cache invalidation), this can be a significant +performance win. \ No newline at end of file diff --git a/src/StackExchange.Redis/KeyNotification.cs b/src/StackExchange.Redis/KeyNotification.cs index 9124d1a6a..493e417da 100644 --- a/src/StackExchange.Redis/KeyNotification.cs +++ b/src/StackExchange.Redis/KeyNotification.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Buffers.Text; using System.Diagnostics; using System.Text; @@ -116,6 +117,7 @@ public RedisKey GetKey() /// /// Get the number of bytes in the key. /// + /// If a scratch-buffer is required, it may be preferable to use , which is less expensive. public int GetKeyByteCount() { if (IsKeySpace) @@ -131,6 +133,24 @@ public int GetKeyByteCount() return 0; } + /// + /// Get the maximum number of bytes in the key. + /// + public int GetKeyMaxByteCount() + { + if (IsKeySpace) + { + return ChannelSuffix.Length; + } + + if (IsKeyEvent) + { + return _value.GetMaxByteCount(); + } + + return 0; + } + /// /// Get the maximum number of characters in the key, interpreting as UTF8. /// @@ -241,6 +261,40 @@ private ReadOnlySpan ChannelSuffix } } + /// + /// Indicates whether this notification is of the given type, specified as raw bytes. + /// + /// This is especially useful for working with unknown event types, but repeated calls to this method will be more expensive than + /// a single successful call to . + public bool IsType(ReadOnlySpan type) + { + if (IsKeySpace) + { + if (_value.TryGetSpan(out var direct)) + { + return direct.SequenceEqual(type); + } + + const int MAX_STACK = 64; + byte[]? lease = null; + var maxCount = _value.GetMaxByteCount(); + Span localCopy = maxCount <= MAX_STACK + ? stackalloc byte[MAX_STACK] + : (lease = ArrayPool.Shared.Rent(maxCount)); + var count = _value.CopyTo(localCopy); + bool result = localCopy.Slice(0, count).SequenceEqual(type); + if (lease is not null) ArrayPool.Shared.Return(lease); + return result; + } + + if (IsKeyEvent) + { + return ChannelSuffix.SequenceEqual(type); + } + + return false; + } + /// /// The type of notification associated with this event, if it is well-known - otherwise . /// diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index fcf301471..2772d5851 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -3,8 +3,10 @@ StackExchange.Redis.KeyNotification StackExchange.Redis.KeyNotification.GetChannel() -> StackExchange.Redis.RedisChannel StackExchange.Redis.KeyNotification.GetKeyByteCount() -> int StackExchange.Redis.KeyNotification.GetKeyCharCount() -> int +StackExchange.Redis.KeyNotification.GetKeyMaxByteCount() -> int StackExchange.Redis.KeyNotification.GetKeyMaxCharCount() -> int StackExchange.Redis.KeyNotification.GetValue() -> StackExchange.Redis.RedisValue +StackExchange.Redis.KeyNotification.IsType(System.ReadOnlySpan type) -> bool StackExchange.Redis.KeyNotification.KeyStartsWith(System.ReadOnlySpan prefix) -> bool StackExchange.Redis.KeyNotification.TryCopyKey(System.Span destination, out int charsWritten) -> bool StackExchange.Redis.KeyNotification.Database.get -> int @@ -14,9 +16,6 @@ StackExchange.Redis.KeyNotification.IsKeySpace.get -> bool StackExchange.Redis.KeyNotification.KeyNotification() -> void StackExchange.Redis.KeyNotification.TryCopyKey(System.Span destination, out int bytesWritten) -> bool StackExchange.Redis.KeyNotification.Type.get -> StackExchange.Redis.KeyNotificationType -StackExchange.Redis.RedisValue.CopyTo(System.Span destination) -> int -StackExchange.Redis.RedisValue.GetCharCount() -> int -StackExchange.Redis.RedisValue.GetMaxCharCount() -> int StackExchange.Redis.RedisValue.StartsWith(System.ReadOnlySpan value) -> bool static StackExchange.Redis.KeyNotification.TryParse(scoped in StackExchange.Redis.RedisChannel channel, scoped in StackExchange.Redis.RedisValue value, out StackExchange.Redis.KeyNotification notification) -> bool StackExchange.Redis.ChannelMessage.TryParseKeyNotification(out StackExchange.Redis.KeyNotification notification) -> bool diff --git a/src/StackExchange.Redis/RedisValue.cs b/src/StackExchange.Redis/RedisValue.cs index 8dd4c635b..46228a912 100644 --- a/src/StackExchange.Redis/RedisValue.cs +++ b/src/StackExchange.Redis/RedisValue.cs @@ -880,10 +880,24 @@ private static string ToHex(ReadOnlySpan src) _ => ThrowUnableToMeasure(), }; + /// + /// Gets the maximum length of the value in bytes. + /// + internal int GetMaxByteCount() => Type switch + { + StorageType.Null => 0, + StorageType.Raw => _memory.Length, + StorageType.String => Encoding.UTF8.GetMaxByteCount(((string)_objectOrSentinel!).Length), + StorageType.Int64 => Format.MaxInt64TextLen, + StorageType.UInt64 => Format.MaxInt64TextLen, + StorageType.Double => Format.MaxDoubleTextLen, + _ => ThrowUnableToMeasure(), + }; + /// /// Gets the length of the value in characters, assuming UTF8 interpretation of BLOB payloads. /// - public int GetCharCount() => Type switch + internal int GetCharCount() => Type switch { StorageType.Null => 0, StorageType.Raw => Encoding.UTF8.GetCharCount(_memory.Span), @@ -897,14 +911,14 @@ private static string ToHex(ReadOnlySpan src) /// /// Gets the length of the value in characters, assuming UTF8 interpretation of BLOB payloads. /// - public int GetMaxCharCount() => Type switch + internal int GetMaxCharCount() => Type switch { StorageType.Null => 0, StorageType.Raw => Encoding.UTF8.GetMaxCharCount(_memory.Length), StorageType.String => ((string)_objectOrSentinel!).Length, - StorageType.Int64 => Format.MeasureInt64(OverlappedValueInt64), - StorageType.UInt64 => Format.MeasureUInt64(OverlappedValueUInt64), - StorageType.Double => Format.MeasureDouble(OverlappedValueDouble), + StorageType.Int64 => Format.MaxInt64TextLen, + StorageType.UInt64 => Format.MaxInt64TextLen, + StorageType.Double => Format.MaxDoubleTextLen, _ => ThrowUnableToMeasure(), }; @@ -946,7 +960,7 @@ public int CopyTo(Span destination) /// /// Copy the value as character data to the provided . /// - public int CopyTo(Span destination) + internal int CopyTo(Span destination) { switch (Type) { diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs index aa18e720d..5b3872b57 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs @@ -20,8 +20,12 @@ public void Keyspace_Del_ParsesCorrectly() Assert.False(notification.IsKeyEvent); Assert.Equal(1, notification.Database); Assert.Equal(KeyNotificationType.Del, notification.Type); + Assert.True(notification.IsType("del"u8)); Assert.Equal("mykey", (string?)notification.GetKey()); Assert.Equal(5, notification.GetKeyByteCount()); + Assert.Equal(5, notification.GetKeyMaxByteCount()); + Assert.Equal(5, notification.GetKeyCharCount()); + Assert.Equal(6, notification.GetKeyMaxCharCount()); } [Fact] @@ -37,8 +41,12 @@ public void Keyevent_Del_ParsesCorrectly() Assert.True(notification.IsKeyEvent); Assert.Equal(42, notification.Database); Assert.Equal(KeyNotificationType.Del, notification.Type); + Assert.True(notification.IsType("del"u8)); Assert.Equal("mykey", (string?)notification.GetKey()); Assert.Equal(5, notification.GetKeyByteCount()); + Assert.Equal(18, notification.GetKeyMaxByteCount()); + Assert.Equal(5, notification.GetKeyCharCount()); + Assert.Equal(5, notification.GetKeyMaxCharCount()); } [Fact] @@ -52,7 +60,12 @@ public void Keyspace_Set_ParsesCorrectly() Assert.True(notification.IsKeySpace); Assert.Equal(0, notification.Database); Assert.Equal(KeyNotificationType.Set, notification.Type); + Assert.True(notification.IsType("set"u8)); Assert.Equal("testkey", (string?)notification.GetKey()); + Assert.Equal(7, notification.GetKeyByteCount()); + Assert.Equal(7, notification.GetKeyMaxByteCount()); + Assert.Equal(7, notification.GetKeyCharCount()); + Assert.Equal(8, notification.GetKeyMaxCharCount()); } [Fact] @@ -66,7 +79,12 @@ public void Keyevent_Expire_ParsesCorrectly() Assert.True(notification.IsKeyEvent); Assert.Equal(5, notification.Database); Assert.Equal(KeyNotificationType.Expire, notification.Type); + Assert.True(notification.IsType("expire"u8)); Assert.Equal("session:12345", (string?)notification.GetKey()); + Assert.Equal(13, notification.GetKeyByteCount()); + Assert.Equal(42, notification.GetKeyMaxByteCount()); + Assert.Equal(13, notification.GetKeyCharCount()); + Assert.Equal(13, notification.GetKeyMaxCharCount()); } [Fact] @@ -80,7 +98,12 @@ public void Keyspace_Expired_ParsesCorrectly() Assert.True(notification.IsKeySpace); Assert.Equal(3, notification.Database); Assert.Equal(KeyNotificationType.Expired, notification.Type); + Assert.True(notification.IsType("expired"u8)); Assert.Equal("cache:item", (string?)notification.GetKey()); + Assert.Equal(10, notification.GetKeyByteCount()); + Assert.Equal(10, notification.GetKeyMaxByteCount()); + Assert.Equal(10, notification.GetKeyCharCount()); + Assert.Equal(11, notification.GetKeyMaxCharCount()); } [Fact] @@ -94,7 +117,12 @@ public void Keyevent_LPush_ParsesCorrectly() Assert.True(notification.IsKeyEvent); Assert.Equal(0, notification.Database); Assert.Equal(KeyNotificationType.LPush, notification.Type); + Assert.True(notification.IsType("lpush"u8)); Assert.Equal("queue:tasks", (string?)notification.GetKey()); + Assert.Equal(11, notification.GetKeyByteCount()); + Assert.Equal(36, notification.GetKeyMaxByteCount()); + Assert.Equal(11, notification.GetKeyCharCount()); + Assert.Equal(11, notification.GetKeyMaxCharCount()); } [Fact] @@ -108,7 +136,12 @@ public void Keyspace_HSet_ParsesCorrectly() Assert.True(notification.IsKeySpace); Assert.Equal(2, notification.Database); Assert.Equal(KeyNotificationType.HSet, notification.Type); + Assert.True(notification.IsType("hset"u8)); Assert.Equal("user:1000", (string?)notification.GetKey()); + Assert.Equal(9, notification.GetKeyByteCount()); + Assert.Equal(9, notification.GetKeyMaxByteCount()); + Assert.Equal(9, notification.GetKeyCharCount()); + Assert.Equal(10, notification.GetKeyMaxCharCount()); } [Fact] @@ -122,7 +155,32 @@ public void Keyevent_ZAdd_ParsesCorrectly() Assert.True(notification.IsKeyEvent); Assert.Equal(7, notification.Database); Assert.Equal(KeyNotificationType.ZAdd, notification.Type); + Assert.True(notification.IsType("zadd"u8)); Assert.Equal("leaderboard", (string?)notification.GetKey()); + Assert.Equal(11, notification.GetKeyByteCount()); + Assert.Equal(36, notification.GetKeyMaxByteCount()); + Assert.Equal(11, notification.GetKeyCharCount()); + Assert.Equal(11, notification.GetKeyMaxCharCount()); + } + + [Fact] + public void CustomEventWithUnusualValue_Works() + { + var channel = RedisChannel.Literal("__keyevent@7__:flooble"); + RedisValue value = 17.5; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.True(notification.IsKeyEvent); + Assert.Equal(7, notification.Database); + Assert.Equal(KeyNotificationType.Unknown, notification.Type); + Assert.False(notification.IsType("zadd"u8)); + Assert.True(notification.IsType("flooble"u8)); + Assert.Equal("17.5", (string?)notification.GetKey()); + Assert.Equal(4, notification.GetKeyByteCount()); + Assert.Equal(40, notification.GetKeyMaxByteCount()); + Assert.Equal(4, notification.GetKeyCharCount()); + Assert.Equal(40, notification.GetKeyMaxCharCount()); } [Fact] @@ -183,6 +241,7 @@ public void Keyspace_UnknownEventType_ReturnsUnknown() Assert.True(notification.IsKeySpace); Assert.Equal(0, notification.Database); Assert.Equal(KeyNotificationType.Unknown, notification.Type); + Assert.False(notification.IsType("del"u8)); Assert.Equal("mykey", (string?)notification.GetKey()); } @@ -197,6 +256,7 @@ public void Keyevent_UnknownEventType_ReturnsUnknown() Assert.True(notification.IsKeyEvent); Assert.Equal(0, notification.Database); Assert.Equal(KeyNotificationType.Unknown, notification.Type); + Assert.False(notification.IsType("del"u8)); Assert.Equal("mykey", (string?)notification.GetKey()); } @@ -211,6 +271,7 @@ public void Keyspace_WithColonInKey_ParsesCorrectly() Assert.True(notification.IsKeySpace); Assert.Equal(0, notification.Database); Assert.Equal(KeyNotificationType.Del, notification.Type); + Assert.True(notification.IsType("del"u8)); Assert.Equal("user:session:12345", (string?)notification.GetKey()); } @@ -225,6 +286,7 @@ public void Keyevent_Evicted_ParsesCorrectly() Assert.True(notification.IsKeyEvent); Assert.Equal(1, notification.Database); Assert.Equal(KeyNotificationType.Evicted, notification.Type); + Assert.True(notification.IsType("evicted"u8)); Assert.Equal("cache:old", (string?)notification.GetKey()); } @@ -239,6 +301,7 @@ public void Keyspace_New_ParsesCorrectly() Assert.True(notification.IsKeySpace); Assert.Equal(0, notification.Database); Assert.Equal(KeyNotificationType.New, notification.Type); + Assert.True(notification.IsType("new"u8)); Assert.Equal("newkey", (string?)notification.GetKey()); } @@ -253,6 +316,7 @@ public void Keyevent_XGroupCreate_ParsesCorrectly() Assert.True(notification.IsKeyEvent); Assert.Equal(0, notification.Database); Assert.Equal(KeyNotificationType.XGroupCreate, notification.Type); + Assert.True(notification.IsType("xgroup-create"u8)); Assert.Equal("mystream", (string?)notification.GetKey()); } @@ -267,6 +331,7 @@ public void Keyspace_TypeChanged_ParsesCorrectly() Assert.True(notification.IsKeySpace); Assert.Equal(0, notification.Database); Assert.Equal(KeyNotificationType.TypeChanged, notification.Type); + Assert.True(notification.IsType("type_changed"u8)); Assert.Equal("mykey", (string?)notification.GetKey()); } @@ -281,6 +346,7 @@ public void Keyevent_HighDatabaseNumber_ParsesCorrectly() Assert.True(notification.IsKeyEvent); Assert.Equal(999, notification.Database); Assert.Equal(KeyNotificationType.Set, notification.Type); + Assert.True(notification.IsType("set"u8)); Assert.Equal("testkey", (string?)notification.GetKey()); } @@ -295,6 +361,7 @@ public void Keyevent_NonIntegerDatabase_ParsesWellEnough() Assert.True(notification.IsKeyEvent); Assert.Equal(-1, notification.Database); Assert.Equal(KeyNotificationType.Set, notification.Type); + Assert.True(notification.IsType("set"u8)); Assert.Equal("testkey", (string?)notification.GetKey()); } @@ -307,8 +374,12 @@ public void DefaultKeyNotification_HasExpectedProperties() Assert.False(notification.IsKeyEvent); Assert.Equal(-1, notification.Database); Assert.Equal(KeyNotificationType.Unknown, notification.Type); + Assert.False(notification.IsType("del"u8)); Assert.True(notification.GetKey().IsNull); Assert.Equal(0, notification.GetKeyByteCount()); + Assert.Equal(0, notification.GetKeyMaxByteCount()); + Assert.Equal(0, notification.GetKeyCharCount()); + Assert.Equal(0, notification.GetKeyMaxCharCount()); Assert.True(notification.GetChannel().IsNull); Assert.True(notification.GetValue().IsNull); From 1041fa3ac2d01290457e1eb6b7aacc80c7be8557 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 29 Jan 2026 16:25:59 +0000 Subject: [PATCH 21/37] docs --- docs/KeyspaceNotifications.md | 5 +++++ docs/index.md | 1 + 2 files changed, 6 insertions(+) diff --git a/docs/KeyspaceNotifications.md b/docs/KeyspaceNotifications.md index 2a8f97507..8040f5859 100644 --- a/docs/KeyspaceNotifications.md +++ b/docs/KeyspaceNotifications.md @@ -75,6 +75,11 @@ sub.Subscribe(channel, (recvChannel, recvValue) => }); ``` +Note that the channels created by the `KeySpace...` and `KeyEvent...` methods cannot be used to manually *publish* events, +only to subscribe to them. The events are published automatically by the Redis server when keys are modified. If you +want to simulate keyspace notifications by publishing events manually, you should use regular pub/sub channels that avoid +the `__keyspace@` and `__keyevent@` prefixes. + ## Performance considerations for KeyNotification The `KeyNotification` struct provides parsed notification data, including (as already shown) the key, event type, diff --git a/docs/index.md b/docs/index.md index 0b4d9bb2e..b1498d878 100644 --- a/docs/index.md +++ b/docs/index.md @@ -39,6 +39,7 @@ Documentation - [Transactions](Transactions) - how atomic transactions work in redis - [Events](Events) - the events available for logging / information purposes - [Pub/Sub Message Order](PubSubOrder) - advice on sequential and concurrent processing +- [Pub/Sub Key Notifications](KeyspaceNotifications) - how to use keyspace and keyevent notifications - [Using RESP3](Resp3) - information on using RESP3 - [ServerMaintenanceEvent](ServerMaintenanceEvent) - how to listen and prepare for hosted server maintenance (e.g. Azure Cache for Redis) - [Streams](Streams) - how to use the Stream data type From 4cf92afbd78d147438f26140dec6c02855a425b4 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 29 Jan 2026 17:08:34 +0000 Subject: [PATCH 22/37] fix routing for single-key channels --- .../PublicAPI/PublicAPI.Unshipped.txt | 2 +- src/StackExchange.Redis/RedisChannel.cs | 29 ++++++++++- .../ServerSelectionStrategy.cs | 2 +- .../KeyNotificationTests.cs | 22 ++++++++- .../PubSubKeyNotificationTests.cs | 49 +++++++++++++++++++ 5 files changed, 99 insertions(+), 5 deletions(-) diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 2772d5851..e9bcae966 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -21,7 +21,6 @@ static StackExchange.Redis.KeyNotification.TryParse(scoped in StackExchange.Redi StackExchange.Redis.ChannelMessage.TryParseKeyNotification(out StackExchange.Redis.KeyNotification notification) -> bool static StackExchange.Redis.RedisChannel.KeyEvent(StackExchange.Redis.KeyNotificationType type, int? database = null) -> StackExchange.Redis.RedisChannel static StackExchange.Redis.RedisChannel.KeyEvent(System.ReadOnlySpan type, int? database) -> StackExchange.Redis.RedisChannel -static StackExchange.Redis.RedisChannel.KeySpace(in StackExchange.Redis.RedisKey key, int database) -> StackExchange.Redis.RedisChannel static StackExchange.Redis.RedisChannel.KeySpacePattern(in StackExchange.Redis.RedisKey pattern, int? database = null) -> StackExchange.Redis.RedisChannel StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.Append = 1 -> StackExchange.Redis.KeyNotificationType @@ -80,3 +79,4 @@ StackExchange.Redis.KeyNotificationType.ZRemByRank = 47 -> StackExchange.Redis.K StackExchange.Redis.KeyNotificationType.ZRemByScore = 48 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.ZUnionStore = 45 -> StackExchange.Redis.KeyNotificationType static StackExchange.Redis.RedisChannel.KeySpacePrefix(System.ReadOnlySpan prefix, int? database = null) -> StackExchange.Redis.RedisChannel +static StackExchange.Redis.RedisChannel.KeySpaceSingleKey(in StackExchange.Redis.RedisKey key, int database) -> StackExchange.Redis.RedisChannel diff --git a/src/StackExchange.Redis/RedisChannel.cs b/src/StackExchange.Redis/RedisChannel.cs index b4310431f..565f1bbc1 100644 --- a/src/StackExchange.Redis/RedisChannel.cs +++ b/src/StackExchange.Redis/RedisChannel.cs @@ -13,6 +13,33 @@ namespace StackExchange.Redis internal ReadOnlySpan Span => Value is null ? default : Value.AsSpan(); + internal ReadOnlySpan RoutingSpan + { + get + { + var span = Span; + if ((Options & (RedisChannelOptions.KeyRouted | RedisChannelOptions.Pattern | + RedisChannelOptions.Sharded | RedisChannelOptions.MultiNode)) == RedisChannelOptions.KeyRouted) + { + // this *could* be a single-key __keyspace@{db}__:{key} subscription, in which case we want to use the key + // part for routing, but to avoid overhead we'll only even look if the channel starts with an underscore + if (span.Length >= 16 && span[0] == (byte)'_') span = StripKeySpacePrefix(span); + } + return span; + } + } + + internal static ReadOnlySpan StripKeySpacePrefix(ReadOnlySpan span) + { + if (span.Length >= 16 && span.StartsWith("__keyspace@"u8)) + { + var subspan = span.Slice(12); + int end = subspan.IndexOf("__:"u8); + if (end >= 0) return subspan.Slice(end + 3); + } + return span; + } + internal readonly RedisChannelOptions Options; [Flags] @@ -176,7 +203,7 @@ public static RedisChannel Sharded(string value) => /// /// Create a key-notification channel for a single key in a single database. /// - public static RedisChannel KeySpace(in RedisKey key, int database) + public static RedisChannel KeySpaceSingleKey(in RedisKey key, int database) => BuildKeySpace(key, database, RedisChannelOptions.KeyRouted); /// diff --git a/src/StackExchange.Redis/ServerSelectionStrategy.cs b/src/StackExchange.Redis/ServerSelectionStrategy.cs index ca247c38b..c176debf8 100644 --- a/src/StackExchange.Redis/ServerSelectionStrategy.cs +++ b/src/StackExchange.Redis/ServerSelectionStrategy.cs @@ -103,7 +103,7 @@ public int HashSlot(in RedisKey key) public int HashSlot(in RedisChannel channel) // note that the RedisChannel->byte[] converter is always direct, so this is not an alloc // (we deal with channels far less frequently, so pay the encoding cost up-front) - => ServerType == ServerType.Standalone || channel.IsNull ? NoSlot : GetClusterSlot((byte[])channel!); + => ServerType == ServerType.Standalone || channel.IsNull ? NoSlot : GetClusterSlot(channel.RoutingSpan); /// /// Gets the hashslot for a given byte sequence. diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs index 5b3872b57..8a3b9dc27 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs @@ -7,6 +7,24 @@ namespace StackExchange.Redis.Tests; public class KeyNotificationTests(ITestOutputHelper log) { + [Theory] + [InlineData("foo", "foo")] + [InlineData("__foo__", "__foo__")] + [InlineData("__keyspace@4__:", "__keyspace@4__:")] // not long enough + [InlineData("__keyspace@4__:f", "f")] + [InlineData("__keyspace@4__:fo", "fo")] + [InlineData("__keyspace@4__:foo", "foo")] + [InlineData("__keyspace@42__:foo", "foo")] // check multi-char db + [InlineData("__keyevent@4__:foo", "__keyevent@4__:foo")] // key-event + [InlineData("__keyevent@42__:foo", "__keyevent@42__:foo")] // key-event + public void RoutingSpan_StripKeySpacePrefix(string raw, string routed) + { + ReadOnlySpan srcBytes = Encoding.UTF8.GetBytes(raw); + var strippedBytes = RedisChannel.StripKeySpacePrefix(srcBytes); + var result = Encoding.UTF8.GetString(strippedBytes); + Assert.Equal(routed, result); + } + [Fact] public void Keyspace_Del_ParsesCorrectly() { @@ -477,7 +495,7 @@ public unsafe void FastHashParse_AllKnownValues_ParseCorrectly(string raw, KeyNo [Fact] public void CreateKeySpaceNotification_Valid() { - var channel = RedisChannel.KeySpace("abc", 42); + var channel = RedisChannel.KeySpaceSingleKey("abc", 42); Assert.Equal("__keyspace@42__:abc", channel.ToString()); Assert.False(channel.IsMultiNode); Assert.True(channel.IsKeyRouted); @@ -525,7 +543,7 @@ public void CreateKeyEventNotification(KeyNotificationType type, int? database, [Fact] public void Cannot_KeyRoute_KeySpace_SingleKeyIsKeyRouted() { - var channel = RedisChannel.KeySpace("abc", 42); + var channel = RedisChannel.KeySpaceSingleKey("abc", 42); Assert.False(channel.IsMultiNode); Assert.True(channel.IsKeyRouted); Assert.True(channel.WithKeyRouting().IsKeyRouted); // no change, still key-routed diff --git a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs index b2149d56a..cf7410738 100644 --- a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs @@ -117,6 +117,8 @@ public async Task KeyEvent_CanObserveSimple_ViaCallbackHandler() var keys = InventKeys(out var prefix); var channel = RedisChannel.KeyEvent(KeyNotificationType.SAdd, db.Database); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsPattern); Log($"Monitoring channel: {channel}"); var sub = conn.GetSubscriber(); await sub.UnsubscribeAsync(channel); @@ -151,6 +153,8 @@ public async Task KeyEvent_CanObserveSimple_ViaQueue() var keys = InventKeys(out var prefix); var channel = RedisChannel.KeyEvent(KeyNotificationType.SAdd, db.Database); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsPattern); Log($"Monitoring channel: {channel}"); var sub = conn.GetSubscriber(); await sub.UnsubscribeAsync(channel); @@ -189,6 +193,8 @@ public async Task KeyNotification_CanObserveSimple_ViaCallbackHandler() var keys = InventKeys(out var prefix); var channel = RedisChannel.KeySpacePrefix(prefix, db.Database); + Assert.True(channel.IsMultiNode); + Assert.True(channel.IsPattern); Log($"Monitoring channel: {channel}"); var sub = conn.GetSubscriber(); await sub.UnsubscribeAsync(channel); @@ -227,6 +233,8 @@ public async Task KeyNotification_CanObserveSimple_ViaQueue() var keys = InventKeys(out var prefix); var channel = RedisChannel.KeySpacePrefix(prefix, db.Database); + Assert.True(channel.IsMultiNode); + Assert.True(channel.IsPattern); Log($"Monitoring channel: {channel}"); var sub = conn.GetSubscriber(); await sub.UnsubscribeAsync(channel); @@ -253,6 +261,47 @@ await sub.SubscribeAsync(channel, (recvChannel, recvValue) => await sub.UnsubscribeAsync(channel); } + [Fact] + public async Task KeyNotification_CanObserveSingleKey_ViaQueue() + { + await using var conn = Create(); + var db = conn.GetDatabase(); + + var keys = InventKeys(out var prefix, count: 1); + var channel = RedisChannel.KeySpaceSingleKey(keys.Single(), db.Database); + Assert.False(channel.IsMultiNode); + Assert.False(channel.IsPattern); + Log($"Monitoring channel: {channel}, routing via {Encoding.UTF8.GetString(channel.RoutingSpan)}"); + + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + Counter callbackCount = new(), matchingEventCount = new(); + TaskCompletionSource allDone = new(); + + ConcurrentDictionary observedCounts = new(); + foreach (var key in keys) + { + observedCounts[key.ToString()] = new(); + } + + var queue = await sub.SubscribeAsync(channel); + _ = Task.Run(async () => + { + await foreach (var msg in queue.WithCancellation(CancellationToken)) + { + callbackCount.Increment(); + if (msg.TryParseKeyNotification(out var notification) + && notification is { IsKeySpace: true, Type: KeyNotificationType.SAdd }) + { + OnNotification(notification, prefix, matchingEventCount, observedCounts, allDone); + } + } + }); + + await SendAndObserveAsync(keys, db, allDone, callbackCount, observedCounts); + await sub.UnsubscribeAsync(channel); + } + private void OnNotification( in KeyNotification notification, ReadOnlySpan prefix, From 4c91b0955c4b51ebd84ed0c9a9085a77edb70df9 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 30 Jan 2026 11:20:23 +0000 Subject: [PATCH 23/37] Consider keyspace and channel isolation --- docs/KeyspaceNotifications.md | 30 +++++- src/StackExchange.Redis/PhysicalConnection.cs | 2 +- .../PublicAPI/PublicAPI.Unshipped.txt | 1 + src/StackExchange.Redis/RawResult.cs | 19 +++- src/StackExchange.Redis/RedisChannel.cs | 96 ++++++++++--------- src/StackExchange.Redis/RedisKey.cs | 8 ++ src/StackExchange.Redis/Subscription.cs | 9 +- .../KeyNotificationTests.cs | 66 ++++++++++++- .../PubSubKeyNotificationTests.cs | 72 +++++++++----- 9 files changed, 215 insertions(+), 88 deletions(-) diff --git a/docs/KeyspaceNotifications.md b/docs/KeyspaceNotifications.md index 8040f5859..4bb561f04 100644 --- a/docs/KeyspaceNotifications.md +++ b/docs/KeyspaceNotifications.md @@ -39,9 +39,17 @@ we need to create a `RedisChannel`: var channel = RedisChannel.KeySpacePrefix(prefix: "user:"u8, database: 0); ``` -Note that there are a range of other `KeySpace...` and `KeyEvent...` methods for different scenarios. +Note that there are a range of other `KeySpace...` and `KeyEvent...` methods for different scenarios, including: -Next, we subscribe to the channel and process the notifications; there are two main approaches: callback-based and queue-based. +- `KeySpaceSingleKey` - subscribe to notifications for a single key in a specific database +- `KeySpacePattern` - subscribe to notifications for a key pattern, optionally in a specific database +- `KeySpacePrefix` - subscribe to notifications for all keys with a specific prefix, optionally in a specific database +- `KeyEvent` - subscribe to notifications for a specific event type, optionally in a specific database + +Note that `KeySpacePattern("foo*")` is equivalent to `KeySpacePrefix("foo")`, and will subscribe to all keys beginning with `"foo"`. + +Next, we subscribe to the channel and process the notifications using the normal pub/sub subscription API; there are two +main approaches: queue-based and callback-based. Queue-based: @@ -95,4 +103,20 @@ to get an alternate lookup API that takes a `ReadOnlySpan` instead of a `s the key bytes into a buffer, and then use the alt-lookup API to find the value. This means that we avoid allocating a string for the key entirely, and instead just copy the bytes into a buffer. If we consider that commonly a local cache will *not* contain the key for the majority of notifications (since they are for cache invalidation), this can be a significant -performance win. \ No newline at end of file +performance win. + +## Considerations when using keyspace or channel isolation + +StackExchange.Redis supports the concept of keyspace and channel (pub/sub) isolation. + +Channel isolation is controlled using the `ConfigurationOptioons.ChannelPrefix` option when connecting to Redis. Intentionally, this feature +*is ignored* by the `KeySpace...` and `KeyEvent...` APIs, because they are designed to subscribe to specific channels +that are outside of the control of the client. + +Keyspace isolation is controlled using the `WithKeyPrefix` extension method on `IDatabase`. This is *not* ignored +by the `KeySpace...` and `KeyEvent...` APIs. Since the database and pub/sub APIs are independent, keyspace isolation +*is not applied*. The caller is responsible for ensuring that the prefix is applied consistently when constructing +the `RedisChannel`, and note that when using the `GetKey()` etc features; the key returned will represent the full key, +including any prefix. Consequently, when using keyspace isolation, you should ensure that your notification processing +takes the prefix into account. The `KeyStartsWith` method can be used to efficiently filter out notifications that do not +have the prefix, and then you can slice the retrieved key accordingly. \ No newline at end of file diff --git a/src/StackExchange.Redis/PhysicalConnection.cs b/src/StackExchange.Redis/PhysicalConnection.cs index 57bcd608d..586a077f5 100644 --- a/src/StackExchange.Redis/PhysicalConnection.cs +++ b/src/StackExchange.Redis/PhysicalConnection.cs @@ -820,7 +820,7 @@ internal void Write(in RedisKey key) } internal void Write(in RedisChannel channel) - => WriteUnifiedPrefixedBlob(_ioPipe?.Output, ChannelPrefix, channel.Value); + => WriteUnifiedPrefixedBlob(_ioPipe?.Output, channel.IgnoreChannelPrefix ? null : ChannelPrefix, channel.Value); [MethodImpl(MethodImplOptions.AggressiveInlining)] internal void WriteBulkString(in RedisValue value) diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index e9bcae966..aa3d42273 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -78,5 +78,6 @@ StackExchange.Redis.KeyNotificationType.ZRem = 49 -> StackExchange.Redis.KeyNoti StackExchange.Redis.KeyNotificationType.ZRemByRank = 47 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.ZRemByScore = 48 -> StackExchange.Redis.KeyNotificationType StackExchange.Redis.KeyNotificationType.ZUnionStore = 45 -> StackExchange.Redis.KeyNotificationType +static StackExchange.Redis.RedisChannel.KeySpacePrefix(in StackExchange.Redis.RedisKey prefix, int? database = null) -> StackExchange.Redis.RedisChannel static StackExchange.Redis.RedisChannel.KeySpacePrefix(System.ReadOnlySpan prefix, int? database = null) -> StackExchange.Redis.RedisChannel static StackExchange.Redis.RedisChannel.KeySpaceSingleKey(in StackExchange.Redis.RedisKey key, int database) -> StackExchange.Redis.RedisChannel diff --git a/src/StackExchange.Redis/RawResult.cs b/src/StackExchange.Redis/RawResult.cs index 1ac9f081a..e1c91b74e 100644 --- a/src/StackExchange.Redis/RawResult.cs +++ b/src/StackExchange.Redis/RawResult.cs @@ -161,22 +161,34 @@ public bool MoveNext() } public ReadOnlySequence Current { get; private set; } } + internal RedisChannel AsRedisChannel(byte[]? channelPrefix, RedisChannel.RedisChannelOptions options) { switch (Resp2TypeBulkString) { case ResultType.SimpleString: case ResultType.BulkString: - if (channelPrefix == null) + 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); @@ -270,9 +282,8 @@ internal bool StartsWith(in CommandBytes expected) var rangeToCheck = Payload.Slice(0, len); return new CommandBytes(rangeToCheck).Equals(expected); } - internal bool StartsWith(byte[] expected) + internal bool StartsWith(ReadOnlySpan expected) { - if (expected == null) throw new ArgumentNullException(nameof(expected)); if (expected.Length > Payload.Length) return false; var rangeToCheck = Payload.Slice(0, expected.Length); @@ -282,7 +293,7 @@ internal bool StartsWith(byte[] expected) foreach (var segment in rangeToCheck) { var from = segment.Span; - var to = new Span(expected, offset, from.Length); + var to = expected.Slice(offset, from.Length); if (!from.SequenceEqual(to)) return false; offset += from.Length; diff --git a/src/StackExchange.Redis/RedisChannel.cs b/src/StackExchange.Redis/RedisChannel.cs index 565f1bbc1..889525bd2 100644 --- a/src/StackExchange.Redis/RedisChannel.cs +++ b/src/StackExchange.Redis/RedisChannel.cs @@ -1,5 +1,7 @@ using System; +using System.Buffers; using System.Diagnostics; +using System.Runtime.CompilerServices; using System.Text; namespace StackExchange.Redis @@ -18,8 +20,9 @@ internal ReadOnlySpan RoutingSpan get { var span = Span; - if ((Options & (RedisChannelOptions.KeyRouted | RedisChannelOptions.Pattern | - RedisChannelOptions.Sharded | RedisChannelOptions.MultiNode)) == RedisChannelOptions.KeyRouted) + if ((Options & (RedisChannelOptions.KeyRouted | RedisChannelOptions.IgnoreChannelPrefix | + RedisChannelOptions.Sharded | RedisChannelOptions.MultiNode | RedisChannelOptions.Pattern)) + == (RedisChannelOptions.KeyRouted | RedisChannelOptions.IgnoreChannelPrefix)) { // this *could* be a single-key __keyspace@{db}__:{key} subscription, in which case we want to use the key // part for routing, but to avoid overhead we'll only even look if the channel starts with an underscore @@ -50,11 +53,12 @@ internal enum RedisChannelOptions Sharded = 1 << 1, KeyRouted = 1 << 2, MultiNode = 1 << 3, + IgnoreChannelPrefix = 1 << 4, } // we don't consider Routed for equality - it's an implementation detail, not a fundamental feature private const RedisChannelOptions EqualityMask = - ~(RedisChannelOptions.KeyRouted | RedisChannelOptions.MultiNode); + ~(RedisChannelOptions.KeyRouted | RedisChannelOptions.MultiNode | RedisChannelOptions.IgnoreChannelPrefix); internal RedisCommand GetPublishCommand() { @@ -75,10 +79,15 @@ internal RedisCommand GetPublishCommand() internal bool IsKeyRouted => (Options & RedisChannelOptions.KeyRouted) != 0; /// - /// Should this channel be subscribed to on all nodes? This is only relevant for cluster scenarios and keyspace notifications. + /// Should this channel be subscribed to on all nodes? This is only relevant for cluster scenarios and keyspace notifications. /// internal bool IsMultiNode => (Options & RedisChannelOptions.MultiNode) != 0; + /// + /// Should the channel prefix be ignored when writing this channel. + /// + internal bool IgnoreChannelPrefix => (Options & RedisChannelOptions.IgnoreChannelPrefix) != 0; + /// /// Indicates whether the channel-name is either null or a zero-length value. /// @@ -204,23 +213,37 @@ public static RedisChannel Sharded(string value) => /// Create a key-notification channel for a single key in a single database. /// public static RedisChannel KeySpaceSingleKey(in RedisKey key, int database) - => BuildKeySpace(key, database, RedisChannelOptions.KeyRouted); + // note we can allow patterns, because we aren't using PSUBSCRIBE + => BuildKeySpaceChannel(key, database, RedisChannelOptions.KeyRouted, default, false, true); /// /// Create a key-notification channel for a pattern, optionally in a specified database. /// public static RedisChannel KeySpacePattern(in RedisKey pattern, int? database = null) - => BuildKeySpace(pattern, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode); + => BuildKeySpaceChannel(pattern, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode, default, appendStar: pattern.IsNull, allowKeyPatterns: true); +#pragma warning disable RS0026 // competing overloads - disambiguated via OverloadResolutionPriority /// /// Create a key-notification channel using a raw prefix, optionally in a specified database. /// + public static RedisChannel KeySpacePrefix(in RedisKey prefix, int? database = null) + { + if (prefix.IsEmpty) Throw(); + return BuildKeySpaceChannel(prefix, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode, default, true, false); + static void Throw() => throw new ArgumentNullException(nameof(prefix)); + } + + /// + /// Create a key-notification channel using a raw prefix, optionally in a specified database. + /// + [OverloadResolutionPriority(1)] public static RedisChannel KeySpacePrefix(ReadOnlySpan prefix, int? database = null) { if (prefix.IsEmpty) Throw(); - return BuildKeySpace(RedisKey.Null, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode, prefix); + return BuildKeySpaceChannel(RedisKey.Null, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode, prefix, true, false); static void Throw() => throw new ArgumentNullException(nameof(prefix)); } +#pragma warning restore RS0026 // competing overloads - disambiguated via OverloadResolutionPriority private const int DatabaseScratchBufferSize = 16; // largest non-negative int32 is 10 digits @@ -269,7 +292,7 @@ public static RedisChannel KeyEvent(ReadOnlySpan type, int? database) target = AppendAndAdvance(target, type); Debug.Assert(target.IsEmpty); // should have calculated length correctly - return new RedisChannel(arr, options); + return new RedisChannel(arr, options | RedisChannelOptions.IgnoreChannelPrefix); } private static Span AppendAndAdvance(Span target, scoped ReadOnlySpan value) @@ -278,57 +301,38 @@ private static Span AppendAndAdvance(Span target, scoped ReadOnlySpa return target.Slice(value.Length); } - private static RedisChannel BuildKeySpace(in RedisKey key, int? database, RedisChannelOptions options, ReadOnlySpan prefix = default) + private static RedisChannel BuildKeySpaceChannel(in RedisKey key, int? database, RedisChannelOptions options, ReadOnlySpan suffix, bool appendStar, bool allowKeyPatterns) { - int keyLen; - if (prefix.IsEmpty) - { - if (key.IsNull) - { - if ((options & RedisChannelOptions.Pattern) == 0) throw new ArgumentNullException(nameof(key)); - keyLen = 1; - } - else - { - keyLen = key.TotalLength(); - if (keyLen == 0) throw new ArgumentOutOfRangeException(nameof(key)); - } - } - else - { - keyLen = prefix.Length + 1; // allow for the * - } + int fullKeyLength = key.TotalLength() + suffix.Length + (appendStar ? 1 : 0); + if (appendStar & (options & RedisChannelOptions.Pattern) == 0) throw new ArgumentNullException(nameof(key)); + if (fullKeyLength == 0) throw new ArgumentOutOfRangeException(nameof(key)); var db = AppendDatabase(stackalloc byte[DatabaseScratchBufferSize], database, options); - // __keyspace@{db}__:{key} - var arr = new byte[14 + db.Length + keyLen]; + // __keyspace@{db}__:{key}[*] + var arr = new byte[14 + db.Length + fullKeyLength]; var target = AppendAndAdvance(arr.AsSpan(), "__keyspace@"u8); target = AppendAndAdvance(target, db); target = AppendAndAdvance(target, "__:"u8); - Debug.Assert(keyLen == target.Length); // should have exactly "len" bytes remaining - if (prefix.IsEmpty) + var keySpan = target; // remember this for if we need to check for patterns + var keyLen = key.CopyTo(target); + target = target.Slice(keyLen); + target = AppendAndAdvance(target, suffix); + if (!allowKeyPatterns) { - if (key.IsNull) - { - target[0] = (byte)'*'; - target = target.Slice(1); - } - else - { - target = target.Slice(key.CopyTo(target)); - } + keySpan = keySpan.Slice(0, keyLen + suffix.Length); + if (keySpan.IndexOfAny((byte)'*', (byte)'?', (byte)'[') >= 0) ThrowPattern(); } - else + if (appendStar) { - prefix.CopyTo(target); - target[prefix.Length] = (byte)'*'; - target = target.Slice(prefix.Length + 1); + target[0] = (byte)'*'; + target = target.Slice(1); } + Debug.Assert(target.IsEmpty, "length calculated incorrectly"); + return new RedisChannel(arr, options | RedisChannelOptions.IgnoreChannelPrefix); - Debug.Assert(target.IsEmpty); // should have calculated length correctly - return new RedisChannel(arr, options); + static void ThrowPattern() => throw new ArgumentException("The supplied key contains pattern characters, but patterns are not supported in this context."); } internal RedisChannel(byte[]? value, RedisChannelOptions options) diff --git a/src/StackExchange.Redis/RedisKey.cs b/src/StackExchange.Redis/RedisKey.cs index 0ee83d560..e18e0fb7c 100644 --- a/src/StackExchange.Redis/RedisKey.cs +++ b/src/StackExchange.Redis/RedisKey.cs @@ -395,6 +395,14 @@ internal int TotalLength() => _ => ((byte[])KeyValue).Length, }; + internal int MaxByteCount() => + (KeyPrefix is null ? 0 : KeyPrefix.Length) + KeyValue switch + { + null => 0, + string s => Encoding.UTF8.GetMaxByteCount(s.Length), + _ => ((byte[])KeyValue).Length, + }; + internal int CopyTo(Span destination) { int written = 0; diff --git a/src/StackExchange.Redis/Subscription.cs b/src/StackExchange.Redis/Subscription.cs index 2d877c9eb..987227da5 100644 --- a/src/StackExchange.Redis/Subscription.cs +++ b/src/StackExchange.Redis/Subscription.cs @@ -80,12 +80,12 @@ internal Message GetSubscriptionMessage( CommandFlags flags, bool internalCall) { + const RedisChannel.RedisChannelOptions OPTIONS_MASK = ~( + RedisChannel.RedisChannelOptions.KeyRouted | RedisChannel.RedisChannelOptions.IgnoreChannelPrefix); var command = action switch // note that the Routed flag doesn't impact the message here - just the routing { - SubscriptionAction.Subscribe => (channel.Options & - ~RedisChannel.RedisChannelOptions - .KeyRouted) switch + SubscriptionAction.Subscribe => (channel.Options & OPTIONS_MASK) switch { RedisChannel.RedisChannelOptions.None => RedisCommand.SUBSCRIBE, RedisChannel.RedisChannelOptions.MultiNode => RedisCommand.SUBSCRIBE, @@ -95,8 +95,7 @@ internal Message GetSubscriptionMessage( RedisChannel.RedisChannelOptions.Sharded => RedisCommand.SSUBSCRIBE, _ => Unknown(action, channel.Options), }, - SubscriptionAction.Unsubscribe => (channel.Options & - ~RedisChannel.RedisChannelOptions.KeyRouted) switch + SubscriptionAction.Unsubscribe => (channel.Options & OPTIONS_MASK) switch { RedisChannel.RedisChannelOptions.None => RedisCommand.UNSUBSCRIBE, RedisChannel.RedisChannelOptions.MultiNode => RedisCommand.UNSUBSCRIBE, diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs index 8a3b9dc27..ac4730a53 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs @@ -30,6 +30,7 @@ public void Keyspace_Del_ParsesCorrectly() { // __keyspace@1__:mykey with payload "del" var channel = RedisChannel.Literal("__keyspace@1__:mykey"); + Assert.False(channel.IgnoreChannelPrefix); // because constructed manually RedisValue value = "del"; Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); @@ -501,6 +502,7 @@ public void CreateKeySpaceNotification_Valid() Assert.True(channel.IsKeyRouted); Assert.False(channel.IsSharded); Assert.False(channel.IsPattern); + Assert.True(channel.IgnoreChannelPrefix); } [Theory] @@ -516,6 +518,54 @@ public void CreateKeySpaceNotificationPattern(string? pattern, int? database, st Assert.False(channel.IsKeyRouted); Assert.False(channel.IsSharded); Assert.True(channel.IsPattern); + Assert.True(channel.IgnoreChannelPrefix); + } + + [Theory] + [InlineData("abc", null, "__keyspace@*__:abc*")] + [InlineData("abc", 42, "__keyspace@42__:abc*")] + public void CreateKeySpaceNotificationPrefix_Key(string prefix, int? database, string expected) + { + var channel = RedisChannel.KeySpacePrefix((RedisKey)prefix, database); + Assert.Equal(expected, channel.ToString()); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsKeyRouted); + Assert.False(channel.IsSharded); + Assert.True(channel.IsPattern); + Assert.True(channel.IgnoreChannelPrefix); + } + + [Theory] + [InlineData("abc", null, "__keyspace@*__:abc*")] + [InlineData("abc", 42, "__keyspace@42__:abc*")] + public void CreateKeySpaceNotificationPrefix_Span(string prefix, int? database, string expected) + { + var channel = RedisChannel.KeySpacePrefix((ReadOnlySpan)Encoding.UTF8.GetBytes(prefix), database); + Assert.Equal(expected, channel.ToString()); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsKeyRouted); + Assert.False(channel.IsSharded); + Assert.True(channel.IsPattern); + Assert.True(channel.IgnoreChannelPrefix); + } + + [Theory] + [InlineData("a?bc", null)] + [InlineData("a?bc", 42)] + [InlineData("a*bc", null)] + [InlineData("a*bc", 42)] + [InlineData("a[bc", null)] + [InlineData("a[bc", 42)] + public void CreateKeySpaceNotificationPrefix_DisallowGlob(string prefix, int? database) + { + var bytes = Encoding.UTF8.GetBytes(prefix); + var ex = Assert.Throws(() => + RedisChannel.KeySpacePrefix((RedisKey)bytes, database)); + Assert.StartsWith("The supplied key contains pattern characters, but patterns are not supported in this context.", ex.Message); + + ex = Assert.Throws(() => + RedisChannel.KeySpacePrefix((ReadOnlySpan)bytes, database)); + Assert.StartsWith("The supplied key contains pattern characters, but patterns are not supported in this context.", ex.Message); } [Theory] @@ -530,6 +580,7 @@ public void CreateKeyEventNotification(KeyNotificationType type, int? database, Assert.True(channel.IsMultiNode); Assert.False(channel.IsKeyRouted); Assert.False(channel.IsSharded); + Assert.True(channel.IgnoreChannelPrefix); if (isPattern) { Assert.True(channel.IsPattern); @@ -540,11 +591,17 @@ public void CreateKeyEventNotification(KeyNotificationType type, int? database, } } - [Fact] - public void Cannot_KeyRoute_KeySpace_SingleKeyIsKeyRouted() + [Theory] + [InlineData("abc", "__keyspace@42__:abc")] + [InlineData("a*bc", "__keyspace@42__:a*bc")] // pattern-like is allowed, since not using PSUBSCRIBE + public void Cannot_KeyRoute_KeySpace_SingleKeyIsKeyRouted(string key, string pattern) { - var channel = RedisChannel.KeySpaceSingleKey("abc", 42); + var channel = RedisChannel.KeySpaceSingleKey(key, 42); + Assert.Equal(pattern, channel.ToString()); Assert.False(channel.IsMultiNode); + Assert.False(channel.IsPattern); + Assert.False(channel.IsSharded); + Assert.True(channel.IgnoreChannelPrefix); Assert.True(channel.IsKeyRouted); Assert.True(channel.WithKeyRouting().IsKeyRouted); // no change, still key-routed Assert.Equal(RedisCommand.PUBLISH, channel.GetPublishCommand()); @@ -556,6 +613,7 @@ public void Cannot_KeyRoute_KeySpacePattern() var channel = RedisChannel.KeySpacePattern("abc", 42); Assert.True(channel.IsMultiNode); Assert.False(channel.IsKeyRouted); + Assert.True(channel.IgnoreChannelPrefix); Assert.StartsWith("Key routing is not supported for multi-node channels", Assert.Throws(() => channel.WithKeyRouting()).Message); Assert.StartsWith("Publishing is not supported for multi-node channels", Assert.Throws(() => channel.GetPublishCommand()).Message); } @@ -566,6 +624,7 @@ public void Cannot_KeyRoute_KeyEvent() var channel = RedisChannel.KeyEvent(KeyNotificationType.Set, 42); Assert.True(channel.IsMultiNode); Assert.False(channel.IsKeyRouted); + Assert.True(channel.IgnoreChannelPrefix); Assert.StartsWith("Key routing is not supported for multi-node channels", Assert.Throws(() => channel.WithKeyRouting()).Message); Assert.StartsWith("Publishing is not supported for multi-node channels", Assert.Throws(() => channel.GetPublishCommand()).Message); } @@ -576,6 +635,7 @@ public void Cannot_KeyRoute_KeyEvent_Custom() var channel = RedisChannel.KeyEvent("foo"u8, 42); Assert.True(channel.IsMultiNode); Assert.False(channel.IsKeyRouted); + Assert.True(channel.IgnoreChannelPrefix); Assert.StartsWith("Key routing is not supported for multi-node channels", Assert.Throws(() => channel.WithKeyRouting()).Message); Assert.StartsWith("Publishing is not supported for multi-node channels", Assert.Throws(() => channel.GetPublishCommand()).Message); } diff --git a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs index cf7410738..9373ba93b 100644 --- a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs @@ -3,10 +3,10 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; -using System.Runtime.CompilerServices; using System.Text; using System.Threading; using System.Threading.Tasks; +using StackExchange.Redis.KeyspaceIsolation; using Xunit; namespace StackExchange.Redis.Tests; @@ -31,11 +31,11 @@ public abstract class PubSubKeyNotificationTests(ITestOutputHelper output, ITest private const int DefaultEventCount = 512; private CancellationToken CancellationToken => context.Current.CancellationToken; - private RedisKey[] InventKeys(out byte[] prefix, int count = DefaultKeyCount) + private RedisKey[] InventKeys(out byte[] prefix, int count = DefaultKeyCount, string isolationKeyPrefix = "") { RedisKey[] keys = new RedisKey[count]; var prefixString = $"{Guid.NewGuid()}/"; - prefix = Encoding.UTF8.GetBytes(prefixString); + prefix = Encoding.UTF8.GetBytes(isolationKeyPrefix + prefixString); for (int i = 0; i < count; i++) { keys[i] = $"{prefixString}{Guid.NewGuid()}"; @@ -43,6 +43,11 @@ private RedisKey[] InventKeys(out byte[] prefix, int count = DefaultKeyCount) return keys; } + [Obsolete("Use Create(withChannelPrefix: false) instead", error: true)] + private IInternalConnectionMultiplexer Create() => Create(withChannelPrefix: false); + private IInternalConnectionMultiplexer Create(bool withChannelPrefix) => + Create(channelPrefix: withChannelPrefix ? "prefix:" : null); + private RedisKey SelectKey(RedisKey[] keys) => keys[SharedRandom.Next(0, keys.Length)]; #if NET6_0_OR_GREATER @@ -76,7 +81,7 @@ public async Task KeySpace_Events_Enabled() [Fact] public async Task KeySpace_CanSubscribe_ManualPublish() { - await using var conn = Create(); + await using var conn = Create(withChannelPrefix: false); var db = conn.GetDatabase(); var channel = RedisChannel.KeyEvent("nonesuch"u8, database: null); @@ -109,10 +114,12 @@ private sealed class Counter public int Increment() => Interlocked.Increment(ref _count); } - [Fact] - public async Task KeyEvent_CanObserveSimple_ViaCallbackHandler() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task KeyEvent_CanObserveSimple_ViaCallbackHandler(bool withChannelPrefix) { - await using var conn = Create(); + await using var conn = Create(withChannelPrefix); var db = conn.GetDatabase(); var keys = InventKeys(out var prefix); @@ -145,10 +152,12 @@ await sub.SubscribeAsync(channel, (recvChannel, recvValue) => await sub.UnsubscribeAsync(channel); } - [Fact] - public async Task KeyEvent_CanObserveSimple_ViaQueue() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task KeyEvent_CanObserveSimple_ViaQueue(bool withChannelPrefix) { - await using var conn = Create(); + await using var conn = Create(withChannelPrefix); var db = conn.GetDatabase(); var keys = InventKeys(out var prefix); @@ -185,10 +194,12 @@ public async Task KeyEvent_CanObserveSimple_ViaQueue() await queue.UnsubscribeAsync(); } - [Fact] - public async Task KeyNotification_CanObserveSimple_ViaCallbackHandler() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task KeyNotification_CanObserveSimple_ViaCallbackHandler(bool withChannelPrefix) { - await using var conn = Create(); + await using var conn = Create(withChannelPrefix); var db = conn.GetDatabase(); var keys = InventKeys(out var prefix); @@ -225,10 +236,12 @@ public async Task KeyNotification_CanObserveSimple_ViaCallbackHandler() await sub.UnsubscribeAsync(channel); } - [Fact] - public async Task KeyNotification_CanObserveSimple_ViaQueue() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task KeyNotification_CanObserveSimple_ViaQueue(bool withChannelPrefix) { - await using var conn = Create(); + await using var conn = Create(withChannelPrefix); var db = conn.GetDatabase(); var keys = InventKeys(out var prefix); @@ -261,14 +274,20 @@ await sub.SubscribeAsync(channel, (recvChannel, recvValue) => await sub.UnsubscribeAsync(channel); } - [Fact] - public async Task KeyNotification_CanObserveSingleKey_ViaQueue() + [Theory] + [InlineData(true, false)] + [InlineData(false, false)] + [InlineData(true, true)] + [InlineData(false, true)] + public async Task KeyNotification_CanObserveSingleKey_ViaQueue(bool withChannelPrefix, bool withKeyPrefix) { - await using var conn = Create(); - var db = conn.GetDatabase(); + await using var conn = Create(withChannelPrefix); + string keyPrefix = withKeyPrefix ? "isolated:" : ""; + var db = conn.GetDatabase().WithKeyPrefix(keyPrefix); - var keys = InventKeys(out var prefix, count: 1); - var channel = RedisChannel.KeySpaceSingleKey(keys.Single(), db.Database); + var keys = InventKeys(out var prefix, count: 1, isolationKeyPrefix: keyPrefix); + Log($"Using {Encoding.UTF8.GetString(prefix)} as filter prefix, sample key: {SelectKey(keys)}"); + var channel = RedisChannel.KeySpaceSingleKey(RedisKey.WithPrefix(Encoding.UTF8.GetBytes(keyPrefix), keys.Single()), db.Database); Assert.False(channel.IsMultiNode); Assert.False(channel.IsPattern); Log($"Monitoring channel: {channel}, routing via {Encoding.UTF8.GetString(channel.RoutingSpan)}"); @@ -281,7 +300,7 @@ public async Task KeyNotification_CanObserveSingleKey_ViaQueue() ConcurrentDictionary observedCounts = new(); foreach (var key in keys) { - observedCounts[key.ToString()] = new(); + observedCounts[keyPrefix + key.ToString()] = new(); } var queue = await sub.SubscribeAsync(channel); @@ -298,7 +317,7 @@ public async Task KeyNotification_CanObserveSingleKey_ViaQueue() } }); - await SendAndObserveAsync(keys, db, allDone, callbackCount, observedCounts); + await SendAndObserveAsync(keys, db, allDone, callbackCount, observedCounts, keyPrefix); await sub.UnsubscribeAsync(channel); } @@ -342,7 +361,8 @@ private async Task SendAndObserveAsync( IDatabase db, TaskCompletionSource allDone, Counter callbackCount, - ConcurrentDictionary observedCounts) + ConcurrentDictionary observedCounts, + string keyPrefix = "") { await Task.Delay(300).ForAwait(); // give it a moment to settle @@ -371,7 +391,7 @@ private async Task SendAndObserveAsync( foreach (var key in keys) { - Assert.Equal(sentCounts[key].Count, observedCounts[key.ToString()].Count); + Assert.Equal(sentCounts[key].Count, observedCounts[keyPrefix + key.ToString()].Count); } } From 99b11c9c98c0fb9357107791a10570b0a7f16e8a Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 30 Jan 2026 11:24:57 +0000 Subject: [PATCH 24/37] Update KeyspaceNotifications.md --- docs/KeyspaceNotifications.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/KeyspaceNotifications.md b/docs/KeyspaceNotifications.md index 4bb561f04..83a5224a3 100644 --- a/docs/KeyspaceNotifications.md +++ b/docs/KeyspaceNotifications.md @@ -46,7 +46,7 @@ Note that there are a range of other `KeySpace...` and `KeyEvent...` methods for - `KeySpacePrefix` - subscribe to notifications for all keys with a specific prefix, optionally in a specific database - `KeyEvent` - subscribe to notifications for a specific event type, optionally in a specific database -Note that `KeySpacePattern("foo*")` is equivalent to `KeySpacePrefix("foo")`, and will subscribe to all keys beginning with `"foo"`. +The `KeySpace*` methods are similar, and are presented separately to make the intent clear. For example, `KeySpacePattern("foo*")` is equivalent to `KeySpacePrefix("foo")`, and will subscribe to all keys beginning with `"foo"`. Next, we subscribe to the channel and process the notifications using the normal pub/sub subscription API; there are two main approaches: queue-based and callback-based. @@ -119,4 +119,4 @@ by the `KeySpace...` and `KeyEvent...` APIs. Since the database and pub/sub APIs the `RedisChannel`, and note that when using the `GetKey()` etc features; the key returned will represent the full key, including any prefix. Consequently, when using keyspace isolation, you should ensure that your notification processing takes the prefix into account. The `KeyStartsWith` method can be used to efficiently filter out notifications that do not -have the prefix, and then you can slice the retrieved key accordingly. \ No newline at end of file +have the prefix, and then you can slice the retrieved key accordingly. From c865d46e2b30daeea71aaaa38cb9bce9b3a0271f Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 30 Jan 2026 14:21:42 +0000 Subject: [PATCH 25/37] Much better API for handling keyspace prefixes in KeyNotification --- docs/KeyspaceNotifications.md | 85 +++++++-- src/StackExchange.Redis/ChannelMessage.cs | 11 +- src/StackExchange.Redis/KeyNotification.cs | 163 +++++++++++++++--- .../PublicAPI/PublicAPI.Unshipped.txt | 2 + .../KeyNotificationTests.cs | 49 ++++++ .../PubSubKeyNotificationTests.cs | 21 +-- 6 files changed, 283 insertions(+), 48 deletions(-) diff --git a/docs/KeyspaceNotifications.md b/docs/KeyspaceNotifications.md index 83a5224a3..ecbf71515 100644 --- a/docs/KeyspaceNotifications.md +++ b/docs/KeyspaceNotifications.md @@ -74,7 +74,7 @@ Callback-based: ```csharp sub.Subscribe(channel, (recvChannel, recvValue) => { - if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification)) + if (KeyNotification.TryParse(recvChannel, recvValue, out var notification)) { Console.WriteLine($"Key: {notification.GetKey()}"); Console.WriteLine($"Type: {notification.Type}"); @@ -105,18 +105,83 @@ for the key entirely, and instead just copy the bytes into a buffer. If we consi contain the key for the majority of notifications (since they are for cache invalidation), this can be a significant performance win. +## Considerations when database isolation + +Database isolation is controlled either via the `ConfigurationOptions.DefaultDatabase` option when connecting to Redis, +or by using the `GetDatabase(int? db = null)` method to get a specific database instance. Note that the +`KeySpace...` and `KeyEvent...` APIs may optionally take a database. When a database is specified, subscription will only +respond to notifications for keys in that database. If a database is not specified, the subscription will respond to +notifications for keys in all databases. Often, you will want to pass `db.Database` from the `IDatabase` instance you are +using for your application logic, to ensure that you are monitoring the correct database. When using Redis Cluster, +this usually means database `0`, since Redis Cluster does not usually support multiple databases. + +For example: + +- `RedisChannel.KeySpaceSingleKey("foo", 0)` maps to `SUBSCRIBE __keyspace@0__:foo` +- `RedisChannel.KeySpacePrefix("foo", 0)` maps to `PSUBSCRIBE __keyspace@0__:foo*` +- `RedisChannel.KeySpacePrefix("foo")` maps to `PSUBSCRIBE __keyspace@*__:foo*` +- `RedisChannel.KeyEvent(KeyNotificationType.Set, 0)` maps to `SUBSCRIBE __keyevent@0__:set` +- `RedisChannel.KeyEvent(KeyNotificationType.Set)` maps to `PSUBSCRIBE __keyevent@*__:set` + +Additionally, note that while most of these examples require multi-node subscriptions on Redis Cluster, `KeySpaceSingleKey` +is an exception, and will only subscribe to the single node that owns the key `foo`. + ## Considerations when using keyspace or channel isolation StackExchange.Redis supports the concept of keyspace and channel (pub/sub) isolation. -Channel isolation is controlled using the `ConfigurationOptioons.ChannelPrefix` option when connecting to Redis. Intentionally, this feature -*is ignored* by the `KeySpace...` and `KeyEvent...` APIs, because they are designed to subscribe to specific channels -that are outside of the control of the client. +Channel isolation is controlled using the `ConfigurationOptions.ChannelPrefix` option when connecting to Redis. +Intentionally, this feature *is ignored* by the `KeySpace...` and `KeyEvent...` APIs, because they are designed to +subscribe to specific (server-defined) channels that are outside the control of the client. -Keyspace isolation is controlled using the `WithKeyPrefix` extension method on `IDatabase`. This is *not* ignored +Keyspace isolation is controlled using the `WithKeyPrefix` extension method on `IDatabase`. This is *not* used by the `KeySpace...` and `KeyEvent...` APIs. Since the database and pub/sub APIs are independent, keyspace isolation -*is not applied*. The caller is responsible for ensuring that the prefix is applied consistently when constructing -the `RedisChannel`, and note that when using the `GetKey()` etc features; the key returned will represent the full key, -including any prefix. Consequently, when using keyspace isolation, you should ensure that your notification processing -takes the prefix into account. The `KeyStartsWith` method can be used to efficiently filter out notifications that do not -have the prefix, and then you can slice the retrieved key accordingly. +*is not applied* (and cannot be; consuming code could have zero, one, or multiple databases with different prefixes). +The caller is responsible for ensuring that the prefix is applied appropriately when constructing the `RedisChannel`. + +By default, key-related featured of `KeyNotification` will return the full key reported by the server, +including any prefix. However, the `TryParseKeyNotification` and `TryParse` methods can optionally be passed a +key prefix, which will be used both to filter unwanted notifications and strip the prefix from the key when reading. +It is *possible* to handle keyspace isolation manually by checking the key with `KeyNotification.KeyStartsWith` and +manually trimming the prefix, but it is *recommended* to do this via `TryParseKeyNotification` and `TryParse`. + +As an example, with a multi-tenant scenario using keyspace isolation, we might have in the database code: + +``` c# +// multi-tenant scenario using keyspace isolation +var db = conn.GetDatabase().WithKeyPrefix("client1234:"); + +// we will later commit order data for example: +await db.StringSetAsync("order/123", "ISBN 9789123684434"); +``` + +To observe this, we could use: + +``` c# + +var sub = conn.GetSubscriber(); + +// we could subscribe to the specific client as a prefix: +var channel = RedisChannel.KeySpacePrefix("client1234:order/", db.Database); + +byte[] prefix = Encoding.UTF8.GetBytes("client1234:"); +sub.SubscribeAsync(channel, (channel, value) => +{ + // by including prefix in the TryParse, we filter out notifications that are not for this client + // *and* the key is sliced internally to remove this prefix when reading + if (KeyNotification.TryParse(prefix, channel, value, out var notification)) + { + // if we get here, the key prefix was a match + var key = notification.GetKey(); // will *not* include the "client1234:" prefix + } +}); + +``` + +Alternatively, if we wanted a single handler that observed all clients, we could use: + +``` c# +var channel = RedisChannel.KeySpacePattern("client*:order/*", db.Database); +``` + +with similar code, parsing the client from the key manually, using the full key length. \ No newline at end of file diff --git a/src/StackExchange.Redis/ChannelMessage.cs b/src/StackExchange.Redis/ChannelMessage.cs index 330aedee4..a29454f0c 100644 --- a/src/StackExchange.Redis/ChannelMessage.cs +++ b/src/StackExchange.Redis/ChannelMessage.cs @@ -1,4 +1,6 @@ -namespace StackExchange.Redis; +using System; + +namespace StackExchange.Redis; /// /// Represents a message that is broadcast via publish/subscribe. @@ -61,4 +63,11 @@ internal ChannelMessage(ChannelMessageQueue queue, in RedisChannel channel, in R /// public bool TryParseKeyNotification(out KeyNotification notification) => KeyNotification.TryParse(in _channel, in _message, out notification); + + /// + /// If the channel is either a keyspace or keyevent notification *with the requested prefix*, resolve the key and event type, + /// and remove the prefix when reading the key. + /// + public bool TryParseKeyNotification(ReadOnlySpan keyPrefix, out KeyNotification notification) + => KeyNotification.TryParse(keyPrefix, in _channel, in _message, out notification); } diff --git a/src/StackExchange.Redis/KeyNotification.cs b/src/StackExchange.Redis/KeyNotification.cs index 493e417da..3427c4dce 100644 --- a/src/StackExchange.Redis/KeyNotification.cs +++ b/src/StackExchange.Redis/KeyNotification.cs @@ -2,6 +2,7 @@ using System.Buffers; using System.Buffers.Text; using System.Diagnostics; +using System.Runtime.CompilerServices; using System.Text; using static StackExchange.Redis.KeyNotificationChannels; namespace StackExchange.Redis; @@ -14,12 +15,17 @@ namespace StackExchange.Redis; /// public readonly ref struct KeyNotification { + // effectively we just wrap a channel, but: we've pre-validated that things make sense + private readonly RedisChannel _channel; + private readonly RedisValue _value; + private readonly int _keyOffset; // used to efficiently strip key prefixes + // this type has been designed with the intent of being able to move the entire thing alloc-free in some future // high-throughput callback, potentially with a ReadOnlySpan field for the key fragment; this is // not implemented currently, but is why this is a ref struct /// - /// If the channel is either a keyspace or keyevent notification, parsed the data. + /// If the channel is either a keyspace or keyevent notification, resolve the key and event type. /// public static bool TryParse(scoped in RedisChannel channel, scoped in RedisValue value, out KeyNotification notification) { @@ -51,6 +57,29 @@ public static bool TryParse(scoped in RedisChannel channel, scoped in RedisValue return false; } + /// + /// If the channel is either a keyspace or keyevent notification *with the requested prefix*, resolve the key and event type, + /// and remove the prefix when reading the key. + /// + public static bool TryParse(scoped in ReadOnlySpan keyPrefix, scoped in RedisChannel channel, scoped in RedisValue value, out KeyNotification notification) + { + if (TryParse(in channel, in value, out notification) && notification.KeyStartsWith(keyPrefix)) + { + notification = notification.WithKeySlice(keyPrefix.Length); + return true; + } + + notification = default; + return false; + } + + internal KeyNotification WithKeySlice(int keyPrefixLength) + { + KeyNotification result = this; + Unsafe.AsRef(in result._keyOffset) = keyPrefixLength; + return result; + } + private const int MinSuffixBytes = 5; // need "0__:x" or similar after prefix /// @@ -63,16 +92,15 @@ public static bool TryParse(scoped in RedisChannel channel, scoped in RedisValue /// public RedisValue GetValue() => _value; - // effectively we just wrap a channel, but: we've pre-validated that things make sense - private readonly RedisChannel _channel; - private readonly RedisValue _value; - internal KeyNotification(scoped in RedisChannel channel, scoped in RedisValue value) { _channel = channel; _value = value; + _keyOffset = 0; } + internal int KeyOffset => _keyOffset; + /// /// The database the key is in. If the database cannot be parsed, -1 is returned. /// @@ -102,13 +130,18 @@ public RedisKey GetKey() if (IsKeySpace) { // then the channel contains the key, and the payload contains the event-type - return ChannelSuffix.ToArray(); // create an isolated copy + return ChannelSuffix.Slice(_keyOffset).ToArray(); // create an isolated copy } if (IsKeyEvent) { // then the channel contains the event-type, and the payload contains the key - return (byte[]?)_value; // todo: this could probably side-step + byte[]? blob = _value; + if (_keyOffset != 0 & blob is not null) + { + return blob.AsSpan(_keyOffset).ToArray(); + } + return blob; } return RedisKey.Null; @@ -122,12 +155,12 @@ public int GetKeyByteCount() { if (IsKeySpace) { - return ChannelSuffix.Length; + return ChannelSuffix.Length - _keyOffset; } if (IsKeyEvent) { - return _value.GetByteCount(); + return _value.GetByteCount() - _keyOffset; } return 0; @@ -140,12 +173,12 @@ public int GetKeyMaxByteCount() { if (IsKeySpace) { - return ChannelSuffix.Length; + return ChannelSuffix.Length - _keyOffset; } if (IsKeyEvent) { - return _value.GetMaxByteCount(); + return _value.GetMaxByteCount() - _keyOffset; } return 0; @@ -158,12 +191,12 @@ public int GetKeyMaxCharCount() { if (IsKeySpace) { - return Encoding.UTF8.GetMaxCharCount(ChannelSuffix.Length); + return Encoding.UTF8.GetMaxCharCount(ChannelSuffix.Length - _keyOffset); } if (IsKeyEvent) { - return _value.GetMaxCharCount(); + return _value.GetMaxCharCount() - _keyOffset; } return 0; @@ -177,15 +210,44 @@ public int GetKeyCharCount() { if (IsKeySpace) { - return Encoding.UTF8.GetCharCount(ChannelSuffix); + return Encoding.UTF8.GetCharCount(ChannelSuffix.Slice(_keyOffset)); } if (IsKeyEvent) { - return _value.GetCharCount(); + return _keyOffset == 0 ? _value.GetCharCount() : SlowMeasure(in this); } return 0; + + static int SlowMeasure(in KeyNotification value) + { + var span = value.GetKeySpan(out var lease, stackalloc byte[128]); + var result = Encoding.UTF8.GetCharCount(span); + Return(lease); + return result; + } + } + + private ReadOnlySpan GetKeySpan(out byte[]? lease, Span buffer) // buffer typically stackalloc + { + lease = null; + if (_value.TryGetSpan(out var direct)) + { + return direct.Slice(_keyOffset); + } + var count = _value.GetMaxByteCount(); + if (count > buffer.Length) + { + buffer = lease = ArrayPool.Shared.Rent(count); + } + count = _value.CopyTo(buffer); + return buffer.Slice(_keyOffset, count - _keyOffset); + } + + private static void Return(byte[]? lease) + { + if (lease is not null) ArrayPool.Shared.Return(lease); } /// @@ -195,7 +257,7 @@ public bool TryCopyKey(Span destination, out int bytesWritten) { if (IsKeySpace) { - var suffix = ChannelSuffix; + var suffix = ChannelSuffix.Slice(_keyOffset); bytesWritten = suffix.Length; // assume success if (bytesWritten <= destination.Length) { @@ -206,12 +268,40 @@ public bool TryCopyKey(Span destination, out int bytesWritten) if (IsKeyEvent) { - bytesWritten = _value.GetByteCount(); - if (bytesWritten <= destination.Length) + if (_value.TryGetSpan(out var direct)) { - var tmp = _value.CopyTo(destination); - Debug.Assert(tmp == bytesWritten); - return true; + bytesWritten = direct.Length - _keyOffset; // assume success + if (bytesWritten <= destination.Length) + { + direct.Slice(_keyOffset).CopyTo(destination); + return true; + } + bytesWritten = 0; + return false; + } + + if (_keyOffset == 0) + { + // get the value to do the hard work + bytesWritten = _value.GetByteCount(); + if (bytesWritten <= destination.Length) + { + _value.CopyTo(destination); + return true; + } + bytesWritten = 0; + return false; + } + + return SlowCopy(in this, destination, out bytesWritten); + + static bool SlowCopy(in KeyNotification value, Span destination, out int bytesWritten) + { + var span = value.GetKeySpan(out var lease, stackalloc byte[128]); + bool result = span.TryCopyTo(destination); + bytesWritten = result ? span.Length : 0; + Return(lease); + return result; } } @@ -226,7 +316,7 @@ public bool TryCopyKey(Span destination, out int charsWritten) { if (IsKeySpace) { - var suffix = ChannelSuffix; + var suffix = ChannelSuffix.Slice(_keyOffset); if (Encoding.UTF8.GetMaxCharCount(suffix.Length) <= destination.Length || Encoding.UTF8.GetCharCount(suffix) <= destination.Length) { @@ -237,11 +327,25 @@ public bool TryCopyKey(Span destination, out int charsWritten) if (IsKeyEvent) { - if (_value.GetMaxCharCount() <= destination.Length || _value.GetCharCount() <= destination.Length) + if (_keyOffset == 0) // can use short-cut { - charsWritten = _value.CopyTo(destination); - return true; + if (_value.GetMaxCharCount() <= destination.Length || _value.GetCharCount() <= destination.Length) + { + charsWritten = _value.CopyTo(destination); + return true; + } + } + var span = GetKeySpan(out var lease, stackalloc byte[128]); + charsWritten = 0; + bool result = false; + if (Encoding.UTF8.GetMaxCharCount(span.Length) <= destination.Length || + Encoding.UTF8.GetCharCount(span) <= destination.Length) + { + charsWritten = Encoding.UTF8.GetChars(span, destination); + result = true; } + Return(lease); + return result; } charsWritten = 0; @@ -362,12 +466,17 @@ public bool KeyStartsWith(ReadOnlySpan prefix) // intentionally leading pe { if (IsKeySpace) { - return ChannelSuffix.StartsWith(prefix); + return ChannelSuffix.Slice(_keyOffset).StartsWith(prefix); } if (IsKeyEvent) { - return _value.StartsWith(prefix); + if (_keyOffset == 0) return _value.StartsWith(prefix); + + var span = GetKeySpan(out var lease, stackalloc byte[128]); + bool result = span.StartsWith(prefix); + Return(lease); + return result; } return false; diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index aa3d42273..6e96ed550 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,4 +1,5 @@ #nullable enable +StackExchange.Redis.ChannelMessage.TryParseKeyNotification(System.ReadOnlySpan keyPrefix, out StackExchange.Redis.KeyNotification notification) -> bool StackExchange.Redis.KeyNotification StackExchange.Redis.KeyNotification.GetChannel() -> StackExchange.Redis.RedisChannel StackExchange.Redis.KeyNotification.GetKeyByteCount() -> int @@ -19,6 +20,7 @@ StackExchange.Redis.KeyNotification.Type.get -> StackExchange.Redis.KeyNotificat StackExchange.Redis.RedisValue.StartsWith(System.ReadOnlySpan value) -> bool static StackExchange.Redis.KeyNotification.TryParse(scoped in StackExchange.Redis.RedisChannel channel, scoped in StackExchange.Redis.RedisValue value, out StackExchange.Redis.KeyNotification notification) -> bool StackExchange.Redis.ChannelMessage.TryParseKeyNotification(out StackExchange.Redis.KeyNotification notification) -> bool +static StackExchange.Redis.KeyNotification.TryParse(scoped in System.ReadOnlySpan keyPrefix, scoped in StackExchange.Redis.RedisChannel channel, scoped in StackExchange.Redis.RedisValue value, out StackExchange.Redis.KeyNotification notification) -> bool static StackExchange.Redis.RedisChannel.KeyEvent(StackExchange.Redis.KeyNotificationType type, int? database = null) -> StackExchange.Redis.RedisChannel static StackExchange.Redis.RedisChannel.KeyEvent(System.ReadOnlySpan type, int? database) -> StackExchange.Redis.RedisChannel static StackExchange.Redis.RedisChannel.KeySpacePattern(in StackExchange.Redis.RedisKey pattern, int? database = null) -> StackExchange.Redis.RedisChannel diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs index ac4730a53..60469eb49 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs @@ -2,6 +2,7 @@ using System.Buffers; using System.Text; using Xunit; +using Xunit.Sdk; namespace StackExchange.Redis.Tests; @@ -646,4 +647,52 @@ public void KeyEventPrefix_KeySpacePrefix_Length_Matches() // this is a sanity check for the parsing step in KeyNotification.TryParse Assert.Equal(KeyNotificationChannels.KeySpacePrefix.Length, KeyNotificationChannels.KeyEventPrefix.Length); } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void KeyNotificationKeyStripping(bool asString) + { + Span blob = stackalloc byte[32]; + Span clob = stackalloc char[32]; + + RedisChannel channel = RedisChannel.Literal("__keyevent@0__:sadd"); + RedisValue value = asString ? "mykey:abc" : "mykey:abc"u8.ToArray(); + KeyNotification.TryParse(in channel, in value, out var notification); + Assert.Equal("mykey:abc", (string?)notification.GetKey()); + Assert.True(notification.KeyStartsWith("mykey:"u8)); + Assert.Equal(0, notification.KeyOffset); + + Assert.Equal(9, notification.GetKeyByteCount()); + Assert.Equal(asString ? 30 : 9, notification.GetKeyMaxByteCount()); + Assert.Equal(9, notification.GetKeyCharCount()); + Assert.Equal(asString ? 9 : 10, notification.GetKeyMaxCharCount()); + + Assert.True(notification.TryCopyKey(blob, out var bytesWritten)); + Assert.Equal(9, bytesWritten); + Assert.Equal("mykey:abc", Encoding.UTF8.GetString(blob.Slice(0, bytesWritten))); + + Assert.True(notification.TryCopyKey(clob, out var charsWritten)); + Assert.Equal(9, charsWritten); + Assert.Equal("mykey:abc", clob.Slice(0, charsWritten).ToString()); + + // now with a prefix + notification = notification.WithKeySlice("mykey:"u8.Length); + Assert.Equal("abc", (string?)notification.GetKey()); + Assert.False(notification.KeyStartsWith("mykey:"u8)); + Assert.Equal(6, notification.KeyOffset); + + Assert.Equal(3, notification.GetKeyByteCount()); + Assert.Equal(asString ? 24 : 3, notification.GetKeyMaxByteCount()); + Assert.Equal(3, notification.GetKeyCharCount()); + Assert.Equal(asString ? 3 : 4, notification.GetKeyMaxCharCount()); + + Assert.True(notification.TryCopyKey(blob, out bytesWritten)); + Assert.Equal(3, bytesWritten); + Assert.Equal("abc", Encoding.UTF8.GetString(blob.Slice(0, bytesWritten))); + + Assert.True(notification.TryCopyKey(clob, out charsWritten)); + Assert.Equal(3, charsWritten); + Assert.Equal("abc", clob.Slice(0, charsWritten).ToString()); + } } diff --git a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs index 9373ba93b..723921d45 100644 --- a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs @@ -31,11 +31,11 @@ public abstract class PubSubKeyNotificationTests(ITestOutputHelper output, ITest private const int DefaultEventCount = 512; private CancellationToken CancellationToken => context.Current.CancellationToken; - private RedisKey[] InventKeys(out byte[] prefix, int count = DefaultKeyCount, string isolationKeyPrefix = "") + private RedisKey[] InventKeys(out byte[] prefix, int count = DefaultKeyCount) { RedisKey[] keys = new RedisKey[count]; var prefixString = $"{Guid.NewGuid()}/"; - prefix = Encoding.UTF8.GetBytes(isolationKeyPrefix + prefixString); + prefix = Encoding.UTF8.GetBytes(prefixString); for (int i = 0; i < count; i++) { keys[i] = $"{prefixString}{Guid.NewGuid()}"; @@ -283,11 +283,13 @@ public async Task KeyNotification_CanObserveSingleKey_ViaQueue(bool withChannelP { await using var conn = Create(withChannelPrefix); string keyPrefix = withKeyPrefix ? "isolated:" : ""; + byte[] keyPrefixBytes = Encoding.UTF8.GetBytes(keyPrefix); var db = conn.GetDatabase().WithKeyPrefix(keyPrefix); - var keys = InventKeys(out var prefix, count: 1, isolationKeyPrefix: keyPrefix); + var keys = InventKeys(out var prefix, count: 1); Log($"Using {Encoding.UTF8.GetString(prefix)} as filter prefix, sample key: {SelectKey(keys)}"); - var channel = RedisChannel.KeySpaceSingleKey(RedisKey.WithPrefix(Encoding.UTF8.GetBytes(keyPrefix), keys.Single()), db.Database); + var channel = RedisChannel.KeySpaceSingleKey(RedisKey.WithPrefix(keyPrefixBytes, keys.Single()), db.Database); + Assert.False(channel.IsMultiNode); Assert.False(channel.IsPattern); Log($"Monitoring channel: {channel}, routing via {Encoding.UTF8.GetString(channel.RoutingSpan)}"); @@ -300,7 +302,7 @@ public async Task KeyNotification_CanObserveSingleKey_ViaQueue(bool withChannelP ConcurrentDictionary observedCounts = new(); foreach (var key in keys) { - observedCounts[keyPrefix + key.ToString()] = new(); + observedCounts[key.ToString()] = new(); } var queue = await sub.SubscribeAsync(channel); @@ -309,7 +311,7 @@ public async Task KeyNotification_CanObserveSingleKey_ViaQueue(bool withChannelP await foreach (var msg in queue.WithCancellation(CancellationToken)) { callbackCount.Increment(); - if (msg.TryParseKeyNotification(out var notification) + if (msg.TryParseKeyNotification(keyPrefixBytes, out var notification) && notification is { IsKeySpace: true, Type: KeyNotificationType.SAdd }) { OnNotification(notification, prefix, matchingEventCount, observedCounts, allDone); @@ -317,7 +319,7 @@ public async Task KeyNotification_CanObserveSingleKey_ViaQueue(bool withChannelP } }); - await SendAndObserveAsync(keys, db, allDone, callbackCount, observedCounts, keyPrefix); + await SendAndObserveAsync(keys, db, allDone, callbackCount, observedCounts); await sub.UnsubscribeAsync(channel); } @@ -361,8 +363,7 @@ private async Task SendAndObserveAsync( IDatabase db, TaskCompletionSource allDone, Counter callbackCount, - ConcurrentDictionary observedCounts, - string keyPrefix = "") + ConcurrentDictionary observedCounts) { await Task.Delay(300).ForAwait(); // give it a moment to settle @@ -391,7 +392,7 @@ private async Task SendAndObserveAsync( foreach (var key in keys) { - Assert.Equal(sentCounts[key].Count, observedCounts[keyPrefix + key.ToString()].Count); + Assert.Equal(sentCounts[key].Count, observedCounts[key.ToString()].Count); } } From 9e6603924468211fa4c5c27241be791918debc51 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 30 Jan 2026 14:32:29 +0000 Subject: [PATCH 26/37] clarify docs --- docs/KeyspaceNotifications.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/KeyspaceNotifications.md b/docs/KeyspaceNotifications.md index ecbf71515..923b792bf 100644 --- a/docs/KeyspaceNotifications.md +++ b/docs/KeyspaceNotifications.md @@ -160,8 +160,8 @@ To observe this, we could use: ``` c# var sub = conn.GetSubscriber(); - -// we could subscribe to the specific client as a prefix: + +// subscribe to the specific tenant as a prefix: var channel = RedisChannel.KeySpacePrefix("client1234:order/", db.Database); byte[] prefix = Encoding.UTF8.GetBytes("client1234:"); @@ -172,13 +172,22 @@ sub.SubscribeAsync(channel, (channel, value) => if (KeyNotification.TryParse(prefix, channel, value, out var notification)) { // if we get here, the key prefix was a match - var key = notification.GetKey(); // will *not* include the "client1234:" prefix + var key = notification.GetKey(); // "order/123" - note no prefix + } + + // for contrast only: this is *not* usually the recommended approach + /* + if (KeyNotification.TryParse(channel, value, out notification) + && notification.KeyStartsWith(prefix)) + { + var key = notification.GetKey(); // "client1234:order/123" - note prefix is included } + */ }); ``` -Alternatively, if we wanted a single handler that observed all clients, we could use: +Alternatively, if we wanted a single handler that observed *all* tenants, we could use: ``` c# var channel = RedisChannel.KeySpacePattern("client*:order/*", db.Database); From bcaab9bb446c79f9b6ab031381eec80287d3a6fd Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 30 Jan 2026 14:46:31 +0000 Subject: [PATCH 27/37] docs are hard --- docs/KeyspaceNotifications.md | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/KeyspaceNotifications.md b/docs/KeyspaceNotifications.md index 923b792bf..9fadc7eb2 100644 --- a/docs/KeyspaceNotifications.md +++ b/docs/KeyspaceNotifications.md @@ -126,6 +126,22 @@ For example: Additionally, note that while most of these examples require multi-node subscriptions on Redis Cluster, `KeySpaceSingleKey` is an exception, and will only subscribe to the single node that owns the key `foo`. +When subscribing without specifying a database (i.e. listening to changes in all database), the database relating +to the notification can be fetched via `KeyNotification.Database`: + +``` c# +var channel = RedisChannel.KeySpacePrefix("foo"); +sub.SubscribeAsync(channel, (recvChannel, recvValue) => +{ + if (KeyNotification.TryParse(recvChannel, recvValue, out var notification)) + { + var key = notification.GetKey(); + var db = notification.Database; + // ... + } +} +``` + ## Considerations when using keyspace or channel isolation StackExchange.Redis supports the concept of keyspace and channel (pub/sub) isolation. @@ -165,22 +181,24 @@ var sub = conn.GetSubscriber(); var channel = RedisChannel.KeySpacePrefix("client1234:order/", db.Database); byte[] prefix = Encoding.UTF8.GetBytes("client1234:"); -sub.SubscribeAsync(channel, (channel, value) => +sub.SubscribeAsync(channel, (recvChannel, recvValue) => { // by including prefix in the TryParse, we filter out notifications that are not for this client // *and* the key is sliced internally to remove this prefix when reading - if (KeyNotification.TryParse(prefix, channel, value, out var notification)) + if (KeyNotification.TryParse(prefix, recvChannel, recvValue, out var notification)) { // if we get here, the key prefix was a match var key = notification.GetKey(); // "order/123" - note no prefix + // ... } - // for contrast only: this is *not* usually the recommended approach /* - if (KeyNotification.TryParse(channel, value, out notification) + // for contrast only: this is *not* usually the recommended approach when using keyspace isolation + if (KeyNotification.TryParse(recvChannel, recvValue, out var notification) && notification.KeyStartsWith(prefix)) { var key = notification.GetKey(); // "client1234:order/123" - note prefix is included + // ... } */ }); From 8e31e0fb55a8658bd2ed74bf36145cb21f330c91 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 30 Jan 2026 14:50:49 +0000 Subject: [PATCH 28/37] words --- docs/KeyspaceNotifications.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/KeyspaceNotifications.md b/docs/KeyspaceNotifications.md index 9fadc7eb2..3cb37920d 100644 --- a/docs/KeyspaceNotifications.md +++ b/docs/KeyspaceNotifications.md @@ -165,7 +165,8 @@ As an example, with a multi-tenant scenario using keyspace isolation, we might h ``` c# // multi-tenant scenario using keyspace isolation -var db = conn.GetDatabase().WithKeyPrefix("client1234:"); +byte[] keyPrefix = Encoding.UTF8.GetBytes("client1234:"); +var db = conn.GetDatabase().WithKeyPrefix(keyPrefix); // we will later commit order data for example: await db.StringSetAsync("order/123", "ISBN 9789123684434"); @@ -174,13 +175,11 @@ await db.StringSetAsync("order/123", "ISBN 9789123684434"); To observe this, we could use: ``` c# - var sub = conn.GetSubscriber(); // subscribe to the specific tenant as a prefix: var channel = RedisChannel.KeySpacePrefix("client1234:order/", db.Database); -byte[] prefix = Encoding.UTF8.GetBytes("client1234:"); sub.SubscribeAsync(channel, (recvChannel, recvValue) => { // by including prefix in the TryParse, we filter out notifications that are not for this client @@ -195,7 +194,7 @@ sub.SubscribeAsync(channel, (recvChannel, recvValue) => /* // for contrast only: this is *not* usually the recommended approach when using keyspace isolation if (KeyNotification.TryParse(recvChannel, recvValue, out var notification) - && notification.KeyStartsWith(prefix)) + && notification.KeyStartsWith(keyPrefix)) { var key = notification.GetKey(); // "client1234:order/123" - note prefix is included // ... From a47d6db2d737d10bd9c26bef863565b3287c5eed Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 30 Jan 2026 17:17:55 +0000 Subject: [PATCH 29/37] Fix incorrect routing of pub/sub messages on cluster when using channel prefix --- .../ConnectionMultiplexer.cs | 2 + src/StackExchange.Redis/Message.cs | 27 ++++++---- src/StackExchange.Redis/PhysicalBridge.cs | 2 +- src/StackExchange.Redis/PhysicalConnection.cs | 2 +- src/StackExchange.Redis/RedisDatabase.cs | 4 +- src/StackExchange.Redis/RedisServer.cs | 8 +-- src/StackExchange.Redis/RedisSubscriber.cs | 24 ++++----- src/StackExchange.Redis/ServerEndPoint.cs | 2 +- .../ServerSelectionStrategy.cs | 26 ++++++++++ src/StackExchange.Redis/Subscription.cs | 51 ++++++++++++------- .../StackExchange.Redis.Tests/ClusterTests.cs | 23 ++++++--- 11 files changed, 113 insertions(+), 58 deletions(-) diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index 219ac7cb0..7eb359ab8 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -1282,6 +1282,8 @@ public long OperationCount } } + internal byte[] ChannelPrefix => ((byte[]?)RawConfig.ChannelPrefix) ?? []; + /// /// Reconfigure the current connections based on the existing configuration. /// diff --git a/src/StackExchange.Redis/Message.cs b/src/StackExchange.Redis/Message.cs index 386d426d8..140d054d6 100644 --- a/src/StackExchange.Redis/Message.cs +++ b/src/StackExchange.Redis/Message.cs @@ -244,14 +244,14 @@ public static Message Create(int db, CommandFlags flags, RedisCommand command, i 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, byte[] channelPrefix) => + new CommandChannelMessage(db, flags, command, channel, channelPrefix); - 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 RedisChannel channel, in RedisValue value, byte[] channelPrefix) => + new CommandChannelValueMessage(db, flags, command, channel, value, channelPrefix); - 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 RedisValue value, in RedisChannel channel, byte[] channelPrefix) => + new CommandValueChannelMessage(db, flags, command, value, channel, channelPrefix); 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); @@ -860,17 +860,19 @@ protected override void WriteImpl(PhysicalConnection physical) internal abstract class CommandChannelBase : Message { internal readonly RedisChannel Channel; + private readonly byte[] _channelPrefix; - protected CommandChannelBase(int db, CommandFlags flags, RedisCommand command, in RedisChannel channel) : base(db, flags, command) + protected CommandChannelBase(int db, CommandFlags flags, RedisCommand command, in RedisChannel channel, byte[] channelPrefix) : base(db, flags, command) { channel.AssertNotNull(); Channel = channel; + _channelPrefix = channelPrefix; } public override string CommandAndKey => Command + " " + Channel; public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) - => Channel.IsKeyRouted ? serverSelectionStrategy.HashSlot(Channel) : ServerSelectionStrategy.NoSlot; + => Channel.IsKeyRouted ? serverSelectionStrategy.HashSlot(_channelPrefix, Channel) : ServerSelectionStrategy.NoSlot; } internal abstract class CommandKeyBase : Message @@ -890,7 +892,8 @@ protected CommandKeyBase(int db, CommandFlags flags, RedisCommand command, in Re private sealed class CommandChannelMessage : CommandChannelBase { - public CommandChannelMessage(int db, CommandFlags flags, RedisCommand command, in RedisChannel channel) : base(db, flags, command, channel) + public CommandChannelMessage(int db, CommandFlags flags, RedisCommand command, in RedisChannel channel, byte[] channelPrefix) + : base(db, flags, command, channel, channelPrefix) { } protected override void WriteImpl(PhysicalConnection physical) { @@ -903,7 +906,8 @@ protected override void WriteImpl(PhysicalConnection physical) private sealed class CommandChannelValueMessage : CommandChannelBase { private readonly RedisValue value; - public CommandChannelValueMessage(int db, CommandFlags flags, RedisCommand command, in RedisChannel channel, in RedisValue value) : base(db, flags, command, channel) + public CommandChannelValueMessage(int db, CommandFlags flags, RedisCommand command, in RedisChannel channel, in RedisValue value, byte[] channelPrefix) + : base(db, flags, command, channel, channelPrefix) { value.AssertNotNull(); this.value = value; @@ -1746,7 +1750,8 @@ protected override void WriteImpl(PhysicalConnection physical) private sealed class CommandValueChannelMessage : CommandChannelBase { private readonly RedisValue value; - public CommandValueChannelMessage(int db, CommandFlags flags, RedisCommand command, in RedisValue value, in RedisChannel channel) : base(db, flags, command, channel) + public CommandValueChannelMessage(int db, CommandFlags flags, RedisCommand command, in RedisValue value, in RedisChannel channel, byte[] channelPrefix) + : base(db, flags, command, channel, channelPrefix) { value.AssertNotNull(); this.value = value; diff --git a/src/StackExchange.Redis/PhysicalBridge.cs b/src/StackExchange.Redis/PhysicalBridge.cs index 0d839f4c7..4ec8e1b8e 100644 --- a/src/StackExchange.Redis/PhysicalBridge.cs +++ b/src/StackExchange.Redis/PhysicalBridge.cs @@ -401,7 +401,7 @@ internal void KeepAlive(bool forceRun = false) } else if (commandMap.IsAvailable(RedisCommand.UNSUBSCRIBE)) { - msg = Message.Create(-1, CommandFlags.FireAndForget, RedisCommand.UNSUBSCRIBE, RedisChannel.Literal(Multiplexer.UniqueId)); + msg = Message.Create(-1, CommandFlags.FireAndForget, RedisCommand.UNSUBSCRIBE, RedisChannel.Literal(Multiplexer.UniqueId), Multiplexer.ChannelPrefix); msg.SetSource(ResultProcessor.TrackSubscriptions, null); } break; diff --git a/src/StackExchange.Redis/PhysicalConnection.cs b/src/StackExchange.Redis/PhysicalConnection.cs index 586a077f5..b5a60d995 100644 --- a/src/StackExchange.Redis/PhysicalConnection.cs +++ b/src/StackExchange.Redis/PhysicalConnection.cs @@ -90,7 +90,7 @@ public PhysicalConnection(PhysicalBridge bridge) lastBeatTickCount = 0; connectionType = bridge.ConnectionType; _bridge = new WeakReference(bridge); - ChannelPrefix = bridge.Multiplexer.RawConfig.ChannelPrefix; + ChannelPrefix = bridge.Multiplexer.ChannelPrefix; if (ChannelPrefix?.Length == 0) ChannelPrefix = null; // null tests are easier than null+empty var endpoint = bridge.ServerEndPoint.EndPoint; _physicalName = connectionType + "#" + Interlocked.Increment(ref totalCount) + "@" + Format.ToString(endpoint); diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index 056a5380a..938fdc626 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -1900,7 +1900,7 @@ public Task StringLongestCommonSubsequenceWithMatchesAsync(Redis public long Publish(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) { if (channel.IsNullOrEmpty) throw new ArgumentNullException(nameof(channel)); - var msg = Message.Create(-1, flags, channel.GetPublishCommand(), channel, message); + var msg = Message.Create(-1, flags, channel.GetPublishCommand(), channel, message, multiplexer.ChannelPrefix); // if we're actively subscribed: send via that connection (otherwise, follow normal rules) return ExecuteSync(msg, ResultProcessor.Int64, server: multiplexer.GetSubscribedServer(channel)); } @@ -1908,7 +1908,7 @@ public long Publish(RedisChannel channel, RedisValue message, CommandFlags flags public Task PublishAsync(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) { if (channel.IsNullOrEmpty) throw new ArgumentNullException(nameof(channel)); - var msg = Message.Create(-1, flags, channel.GetPublishCommand(), channel, message); + var msg = Message.Create(-1, flags, channel.GetPublishCommand(), channel, message, multiplexer.ChannelPrefix); // if we're actively subscribed: send via that connection (otherwise, follow normal rules) return ExecuteAsync(msg, ResultProcessor.Int64, server: multiplexer.GetSubscribedServer(channel)); } diff --git a/src/StackExchange.Redis/RedisServer.cs b/src/StackExchange.Redis/RedisServer.cs index 3bc306c69..c03ae921b 100644 --- a/src/StackExchange.Redis/RedisServer.cs +++ b/src/StackExchange.Redis/RedisServer.cs @@ -541,14 +541,14 @@ public Task StringGetAsync(int db, RedisKey key, CommandFlags flags public RedisChannel[] SubscriptionChannels(RedisChannel pattern = default, CommandFlags flags = CommandFlags.None) { var msg = pattern.IsNullOrEmpty ? Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.CHANNELS) - : Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.CHANNELS, pattern); + : Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.CHANNELS, pattern, multiplexer.ChannelPrefix); return ExecuteSync(msg, ResultProcessor.RedisChannelArrayLiteral, defaultValue: Array.Empty()); } public Task SubscriptionChannelsAsync(RedisChannel pattern = default, CommandFlags flags = CommandFlags.None) { var msg = pattern.IsNullOrEmpty ? Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.CHANNELS) - : Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.CHANNELS, pattern); + : Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.CHANNELS, pattern, multiplexer.ChannelPrefix); return ExecuteAsync(msg, ResultProcessor.RedisChannelArrayLiteral, defaultValue: Array.Empty()); } @@ -566,13 +566,13 @@ public Task SubscriptionPatternCountAsync(CommandFlags flags = CommandFlag public long SubscriptionSubscriberCount(RedisChannel channel, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.NUMSUB, channel); + var msg = Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.NUMSUB, channel, multiplexer.ChannelPrefix); return ExecuteSync(msg, ResultProcessor.PubSubNumSub); } public Task SubscriptionSubscriberCountAsync(RedisChannel channel, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.NUMSUB, channel); + var msg = Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.NUMSUB, channel, multiplexer.ChannelPrefix); return ExecuteAsync(msg, ResultProcessor.PubSubNumSub); } diff --git a/src/StackExchange.Redis/RedisSubscriber.cs b/src/StackExchange.Redis/RedisSubscriber.cs index 824aef025..faeb172ff 100644 --- a/src/StackExchange.Redis/RedisSubscriber.cs +++ b/src/StackExchange.Redis/RedisSubscriber.cs @@ -135,7 +135,7 @@ internal long EnsureSubscriptions(CommandFlags flags = CommandFlags.None) var subscriber = DefaultSubscriber; foreach (var pair in subscriptions) { - count += pair.Value.EnsureSubscribedToServer(subscriber, pair.Key, flags, true); + count += pair.Value.EnsureSubscribedToServer(subscriber, pair.Key, flags, true, this); } return count; } @@ -162,14 +162,14 @@ internal RedisSubscriber(ConnectionMultiplexer multiplexer, object? asyncState) public EndPoint? IdentifyEndpoint(RedisChannel channel, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.NUMSUB, channel); + var msg = Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.NUMSUB, channel, multiplexer.ChannelPrefix); msg.SetInternalCall(); return ExecuteSync(msg, ResultProcessor.ConnectionIdentity); } public Task IdentifyEndpointAsync(RedisChannel channel, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.NUMSUB, channel); + var msg = Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.NUMSUB, channel, multiplexer.ChannelPrefix); msg.SetInternalCall(); return ExecuteAsync(msg, ResultProcessor.ConnectionIdentity); } @@ -232,7 +232,7 @@ private static void ThrowIfNull(in RedisChannel channel) public long Publish(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) { ThrowIfNull(channel); - var msg = Message.Create(-1, flags, channel.GetPublishCommand(), channel, message); + var msg = Message.Create(-1, flags, channel.GetPublishCommand(), channel, message, multiplexer.ChannelPrefix); // if we're actively subscribed: send via that connection (otherwise, follow normal rules) return ExecuteSync(msg, ResultProcessor.Int64, server: multiplexer.GetSubscribedServer(channel)); } @@ -240,7 +240,7 @@ public long Publish(RedisChannel channel, RedisValue message, CommandFlags flags public Task PublishAsync(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) { ThrowIfNull(channel); - var msg = Message.Create(-1, flags, channel.GetPublishCommand(), channel, message); + var msg = Message.Create(-1, flags, channel.GetPublishCommand(), channel, message, multiplexer.ChannelPrefix); // if we're actively subscribed: send via that connection (otherwise, follow normal rules) return ExecuteAsync(msg, ResultProcessor.Int64, server: multiplexer.GetSubscribedServer(channel)); } @@ -262,7 +262,7 @@ private int Subscribe(RedisChannel channel, Action? ha var sub = multiplexer.GetOrAddSubscription(channel, flags); sub.Add(handler, queue); - return sub.EnsureSubscribedToServer(this, channel, flags, false); + return sub.EnsureSubscribedToServer(this, channel, flags, false, multiplexer); } internal void ResubscribeToServer(Subscription sub, RedisChannel channel, ServerEndPoint serverEndPoint, string cause) @@ -274,7 +274,7 @@ internal void ResubscribeToServer(Subscription sub, RedisChannel channel, Server { // we'll *try* for a simple resubscribe, following any -MOVED etc, but if that fails: fall back // to full reconfigure; importantly, note that we've already recorded the disconnect - var message = sub.GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, CommandFlags.None, false); + var message = sub.GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, CommandFlags.None, false, multiplexer); _ = ExecuteAsync(message, sub.Processor, serverEndPoint).ContinueWith( t => multiplexer.ReconfigureIfNeeded(serverEndPoint.EndPoint, false, cause: cause), TaskContinuationOptions.OnlyOnFaulted); @@ -305,7 +305,7 @@ private Task SubscribeAsync(RedisChannel channel, Action multiplexer.GetSubscribedServer(channel)?.EndPoint; @@ -319,7 +319,7 @@ public bool Unsubscribe(in RedisChannel channel, Action UnsubscribeAsync(in RedisChannel channel, Action.Default(asyncState); } @@ -371,7 +371,7 @@ public void UnsubscribeAll(CommandFlags flags = CommandFlags.None) if (subs.TryRemove(pair.Key, out var sub)) { sub.MarkCompleted(); - sub.UnsubscribeFromServer(this, pair.Key, flags, false); + sub.UnsubscribeFromServer(this, pair.Key, flags, false, multiplexer); } } } @@ -386,7 +386,7 @@ public Task UnsubscribeAllAsync(CommandFlags flags = CommandFlags.None) if (subs.TryRemove(pair.Key, out var sub)) { sub.MarkCompleted(); - last = sub.UnsubscribeFromServerAsync(this, pair.Key, flags, asyncState, false); + last = sub.UnsubscribeFromServerAsync(this, pair.Key, flags, asyncState, false, multiplexer); } } return last ?? CompletedTask.Default(asyncState); diff --git a/src/StackExchange.Redis/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs index f856a5b21..6762d28ec 100644 --- a/src/StackExchange.Redis/ServerEndPoint.cs +++ b/src/StackExchange.Redis/ServerEndPoint.cs @@ -1078,7 +1078,7 @@ private async Task HandshakeAsync(PhysicalConnection connection, ILogger? log) var configChannel = Multiplexer.ConfigurationChangedChannel; if (configChannel != null) { - msg = Message.Create(-1, CommandFlags.FireAndForget, RedisCommand.SUBSCRIBE, RedisChannel.Literal(configChannel)); + msg = Message.Create(-1, CommandFlags.FireAndForget, RedisCommand.SUBSCRIBE, RedisChannel.Literal(configChannel), Multiplexer.ChannelPrefix); // Note: this is NOT internal, we want it to queue in a backlog for sending when ready if necessary await WriteDirectOrQueueFireAndForgetAsync(connection, msg, ResultProcessor.TrackSubscriptions).ForAwait(); } diff --git a/src/StackExchange.Redis/ServerSelectionStrategy.cs b/src/StackExchange.Redis/ServerSelectionStrategy.cs index c176debf8..26f66f35b 100644 --- a/src/StackExchange.Redis/ServerSelectionStrategy.cs +++ b/src/StackExchange.Redis/ServerSelectionStrategy.cs @@ -105,6 +105,32 @@ public int HashSlot(in RedisChannel channel) // (we deal with channels far less frequently, so pay the encoding cost up-front) => ServerType == ServerType.Standalone || channel.IsNull ? NoSlot : GetClusterSlot(channel.RoutingSpan); + internal int HashSlot(byte[] prefix, in RedisChannel channel) + { + if (ServerType == ServerType.Standalone || channel.IsNull) return NoSlot; + + return prefix.Length == 0 | channel.IgnoreChannelPrefix + ? GetClusterSlot(channel.RoutingSpan) + : WithPrefix(prefix, channel); + + static int WithPrefix(byte[] prefix, in RedisChannel channel) + { + const int MAX_STACK = 128; + byte[]? lease = null; + var routingSpan = channel.RoutingSpan; + var totalLength = prefix.Length + routingSpan.Length; + var span = totalLength <= MAX_STACK + ? stackalloc byte[MAX_STACK] + : (lease = ArrayPool.Shared.Rent(totalLength)); + + prefix.CopyTo(span); + routingSpan.CopyTo(span.Slice(prefix.Length)); + var result = GetClusterSlot(span.Slice(0, totalLength)); + if (lease is not null) ArrayPool.Shared.Return(lease); + return result; + } + } + /// /// Gets the hashslot for a given byte sequence. /// diff --git a/src/StackExchange.Redis/Subscription.cs b/src/StackExchange.Redis/Subscription.cs index 987227da5..87e981a48 100644 --- a/src/StackExchange.Redis/Subscription.cs +++ b/src/StackExchange.Redis/Subscription.cs @@ -38,7 +38,8 @@ internal abstract int EnsureSubscribedToServer( RedisSubscriber subscriber, in RedisChannel channel, CommandFlags flags, - bool internalCall); + bool internalCall, + ConnectionMultiplexer multiplexer); // returns the number of changes required internal abstract Task EnsureSubscribedToServerAsync( @@ -46,20 +47,23 @@ internal abstract Task EnsureSubscribedToServerAsync( RedisChannel channel, CommandFlags flags, bool internalCall, + ConnectionMultiplexer multiplexer, ServerEndPoint? server = null); internal abstract bool UnsubscribeFromServer( RedisSubscriber subscriber, in RedisChannel channel, CommandFlags flags, - bool internalCall); + bool internalCall, + ConnectionMultiplexer multiplexer); internal abstract Task UnsubscribeFromServerAsync( RedisSubscriber subscriber, RedisChannel channel, CommandFlags flags, object? asyncState, - bool internalCall); + bool internalCall, + ConnectionMultiplexer multiplexer); internal abstract int GetConnectionCount(); @@ -78,7 +82,8 @@ internal Message GetSubscriptionMessage( RedisChannel channel, SubscriptionAction action, CommandFlags flags, - bool internalCall) + bool internalCall, + ConnectionMultiplexer multiplexer) { const RedisChannel.RedisChannelOptions OPTIONS_MASK = ~( RedisChannel.RedisChannelOptions.KeyRouted | RedisChannel.RedisChannelOptions.IgnoreChannelPrefix); @@ -109,7 +114,7 @@ internal Message GetSubscriptionMessage( }; // TODO: Consider flags here - we need to pass Fire and Forget, but don't want to intermingle Primary/Replica - var msg = Message.Create(-1, Flags | flags, command, channel); + var msg = Message.Create(-1, Flags | flags, command, channel, multiplexer.ChannelPrefix); msg.SetForSubscriptionBridge(); if (internalCall) { @@ -224,12 +229,13 @@ internal override bool UnsubscribeFromServer( RedisSubscriber subscriber, in RedisChannel channel, CommandFlags flags, - bool internalCall) + bool internalCall, + ConnectionMultiplexer multiplexer) { var server = _currentServer; if (server is not null) { - var message = GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall); + var message = GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall, multiplexer); return subscriber.multiplexer.ExecuteSyncImpl(message, Processor, server); } @@ -241,12 +247,13 @@ internal override Task UnsubscribeFromServerAsync( RedisChannel channel, CommandFlags flags, object? asyncState, - bool internalCall) + bool internalCall, + ConnectionMultiplexer multiplexer) { var server = _currentServer; if (server is not null) { - var message = GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall); + var message = GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall, multiplexer); return subscriber.multiplexer.ExecuteAsyncImpl(message, Processor, asyncState, server); } @@ -274,13 +281,14 @@ internal override int EnsureSubscribedToServer( RedisSubscriber subscriber, in RedisChannel channel, CommandFlags flags, - bool internalCall) + bool internalCall, + ConnectionMultiplexer multiplexer) { if (IsConnectedAny()) return 0; // we're not appropriately connected, so blank it out for eligible reconnection _currentServer = null; - var message = GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall); + var message = GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall, multiplexer); var selected = subscriber.multiplexer.SelectServer(message); _ = subscriber.ExecuteSync(message, Processor, selected); return 1; @@ -291,13 +299,14 @@ internal override async Task EnsureSubscribedToServerAsync( RedisChannel channel, CommandFlags flags, bool internalCall, + ConnectionMultiplexer multiplexer, ServerEndPoint? server = null) { if (IsConnectedAny()) return 0; // we're not appropriately connected, so blank it out for eligible reconnection _currentServer = null; - var message = GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall); + var message = GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall, multiplexer); server ??= subscriber.multiplexer.SelectServer(message); await subscriber.ExecuteAsync(message, Processor, server).ForAwait(); return 1; @@ -404,7 +413,8 @@ internal override int EnsureSubscribedToServer( RedisSubscriber subscriber, in RedisChannel channel, CommandFlags flags, - bool internalCall) + bool internalCall, + ConnectionMultiplexer multiplexer) { int delta = 0; var muxer = subscriber.multiplexer; @@ -416,7 +426,7 @@ internal override int EnsureSubscribedToServer( { if (!IsConnectedTo(server.EndPoint)) { - var message = GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall); + var message = GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall, multiplexer); subscriber.ExecuteSync(message, Processor, server); delta++; } @@ -431,6 +441,7 @@ internal override async Task EnsureSubscribedToServerAsync( RedisChannel channel, CommandFlags flags, bool internalCall, + ConnectionMultiplexer multiplexer, ServerEndPoint? server = null) { int delta = 0; @@ -448,7 +459,7 @@ internal override async Task EnsureSubscribedToServerAsync( { if (!IsConnectedTo(loopServer.EndPoint)) { - var message = GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall); + var message = GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall, multiplexer); await subscriber.ExecuteAsync(message, Processor, loopServer).ForAwait(); delta++; } @@ -463,12 +474,13 @@ internal override bool UnsubscribeFromServer( RedisSubscriber subscriber, in RedisChannel channel, CommandFlags flags, - bool internalCall) + bool internalCall, + ConnectionMultiplexer multiplexer) { bool any = false; foreach (var server in _servers) { - var message = GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall); + var message = GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall, multiplexer); any |= subscriber.ExecuteSync(message, Processor, server.Value); } @@ -480,12 +492,13 @@ internal override async Task UnsubscribeFromServerAsync( RedisChannel channel, CommandFlags flags, object? asyncState, - bool internalCall) + bool internalCall, + ConnectionMultiplexer multiplexer) { bool any = false; foreach (var server in _servers) { - var message = GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall); + var message = GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall, multiplexer); any |= await subscriber.ExecuteAsync(message, Processor, server.Value).ForAwait(); } diff --git a/tests/StackExchange.Redis.Tests/ClusterTests.cs b/tests/StackExchange.Redis.Tests/ClusterTests.cs index 8146dc9be..781b65fef 100644 --- a/tests/StackExchange.Redis.Tests/ClusterTests.cs +++ b/tests/StackExchange.Redis.Tests/ClusterTests.cs @@ -743,11 +743,15 @@ public async Task ConnectIncludesSubscriber() } [Theory] - [InlineData(true, false)] - [InlineData(true, true)] - [InlineData(false, false)] - [InlineData(false, true)] - public async Task ClusterPubSub(bool sharded, bool withKeyRouting) + [InlineData(true, false, false)] + [InlineData(true, true, false)] + [InlineData(false, false, false)] + [InlineData(false, true, false)] + [InlineData(true, false, true)] + [InlineData(true, true, true)] + [InlineData(false, false, true)] + [InlineData(false, true, true)] + public async Task ClusterPubSub(bool sharded, bool withKeyRouting, bool withKeyPrefix) { var guid = Guid.NewGuid().ToString(); var channel = sharded ? RedisChannel.Sharded(guid) : RedisChannel.Literal(guid); @@ -755,7 +759,12 @@ public async Task ClusterPubSub(bool sharded, bool withKeyRouting) { channel = channel.WithKeyRouting(); } - await using var conn = Create(keepAlive: 1, connectTimeout: 3000, shared: false, require: sharded ? RedisFeatures.v7_0_0_rc1 : RedisFeatures.v2_0_0); + await using var conn = Create( + keepAlive: 1, + connectTimeout: 3000, + shared: false, + require: sharded ? RedisFeatures.v7_0_0_rc1 : RedisFeatures.v2_0_0, + channelPrefix: withKeyPrefix ? "c_prefix:" : null); Assert.True(conn.IsConnected); var pubsub = conn.GetSubscriber(); @@ -778,7 +787,7 @@ public async Task ClusterPubSub(bool sharded, bool withKeyRouting) } List<(RedisChannel, RedisValue)> received = []; - var queue = await pubsub.SubscribeAsync(channel); + var queue = await pubsub.SubscribeAsync(channel, CommandFlags.NoRedirect); _ = Task.Run(async () => { // use queue API to have control over order From e3c0629f334d78eb7729f339dbb4bc1e04671c2f Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Sun, 1 Feb 2026 11:54:57 +0000 Subject: [PATCH 30/37] simplify channel-prefix passing --- .../ConnectionMultiplexer.cs | 2 + src/StackExchange.Redis/Message.cs | 30 +++++------ src/StackExchange.Redis/PhysicalBridge.cs | 2 +- src/StackExchange.Redis/RedisDatabase.cs | 4 +- src/StackExchange.Redis/RedisServer.cs | 8 +-- src/StackExchange.Redis/RedisSubscriber.cs | 24 ++++----- src/StackExchange.Redis/ServerEndPoint.cs | 2 +- .../ServerSelectionStrategy.cs | 22 ++++---- src/StackExchange.Redis/Subscription.cs | 51 +++++++------------ 9 files changed, 64 insertions(+), 81 deletions(-) diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index 7eb359ab8..0c6148923 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -1282,6 +1282,8 @@ public long OperationCount } } + // note that the RedisChannel->byte[] converter is always direct, so this is not an alloc + // (we deal with channels far less frequently, so pay the encoding cost up-front) internal byte[] ChannelPrefix => ((byte[]?)RawConfig.ChannelPrefix) ?? []; /// diff --git a/src/StackExchange.Redis/Message.cs b/src/StackExchange.Redis/Message.cs index 140d054d6..37472fd4c 100644 --- a/src/StackExchange.Redis/Message.cs +++ b/src/StackExchange.Redis/Message.cs @@ -244,14 +244,14 @@ public static Message Create(int db, CommandFlags flags, RedisCommand command, i 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, byte[] channelPrefix) => - new CommandChannelMessage(db, flags, command, channel, channelPrefix); + 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, byte[] channelPrefix) => - new CommandChannelValueMessage(db, flags, command, channel, value, channelPrefix); + 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, byte[] channelPrefix) => - new CommandValueChannelMessage(db, flags, command, value, channel, channelPrefix); + 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) => new CommandKeyValueValueMessage(db, flags, command, key, value0, value1); @@ -860,19 +860,17 @@ protected override void WriteImpl(PhysicalConnection physical) internal abstract class CommandChannelBase : Message { internal readonly RedisChannel Channel; - private readonly byte[] _channelPrefix; - protected CommandChannelBase(int db, CommandFlags flags, RedisCommand command, in RedisChannel channel, byte[] channelPrefix) : base(db, flags, command) + protected CommandChannelBase(int db, CommandFlags flags, RedisCommand command, in RedisChannel channel) : base(db, flags, command) { channel.AssertNotNull(); Channel = channel; - _channelPrefix = channelPrefix; } public override string CommandAndKey => Command + " " + Channel; public override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) - => Channel.IsKeyRouted ? serverSelectionStrategy.HashSlot(_channelPrefix, Channel) : ServerSelectionStrategy.NoSlot; + => Channel.IsKeyRouted ? serverSelectionStrategy.HashSlot(Channel) : ServerSelectionStrategy.NoSlot; } internal abstract class CommandKeyBase : Message @@ -892,8 +890,8 @@ protected CommandKeyBase(int db, CommandFlags flags, RedisCommand command, in Re private sealed class CommandChannelMessage : CommandChannelBase { - public CommandChannelMessage(int db, CommandFlags flags, RedisCommand command, in RedisChannel channel, byte[] channelPrefix) - : base(db, flags, command, channel, channelPrefix) + public CommandChannelMessage(int db, CommandFlags flags, RedisCommand command, in RedisChannel channel) + : base(db, flags, command, channel) { } protected override void WriteImpl(PhysicalConnection physical) { @@ -906,8 +904,8 @@ protected override void WriteImpl(PhysicalConnection physical) private sealed class CommandChannelValueMessage : CommandChannelBase { private readonly RedisValue value; - public CommandChannelValueMessage(int db, CommandFlags flags, RedisCommand command, in RedisChannel channel, in RedisValue value, byte[] channelPrefix) - : base(db, flags, command, channel, channelPrefix) + public CommandChannelValueMessage(int db, CommandFlags flags, RedisCommand command, in RedisChannel channel, in RedisValue value) + : base(db, flags, command, channel) { value.AssertNotNull(); this.value = value; @@ -1750,8 +1748,8 @@ protected override void WriteImpl(PhysicalConnection physical) private sealed class CommandValueChannelMessage : CommandChannelBase { private readonly RedisValue value; - public CommandValueChannelMessage(int db, CommandFlags flags, RedisCommand command, in RedisValue value, in RedisChannel channel, byte[] channelPrefix) - : base(db, flags, command, channel, channelPrefix) + public CommandValueChannelMessage(int db, CommandFlags flags, RedisCommand command, in RedisValue value, in RedisChannel channel) + : base(db, flags, command, channel) { value.AssertNotNull(); this.value = value; diff --git a/src/StackExchange.Redis/PhysicalBridge.cs b/src/StackExchange.Redis/PhysicalBridge.cs index 4ec8e1b8e..0d839f4c7 100644 --- a/src/StackExchange.Redis/PhysicalBridge.cs +++ b/src/StackExchange.Redis/PhysicalBridge.cs @@ -401,7 +401,7 @@ internal void KeepAlive(bool forceRun = false) } else if (commandMap.IsAvailable(RedisCommand.UNSUBSCRIBE)) { - msg = Message.Create(-1, CommandFlags.FireAndForget, RedisCommand.UNSUBSCRIBE, RedisChannel.Literal(Multiplexer.UniqueId), Multiplexer.ChannelPrefix); + msg = Message.Create(-1, CommandFlags.FireAndForget, RedisCommand.UNSUBSCRIBE, RedisChannel.Literal(Multiplexer.UniqueId)); msg.SetSource(ResultProcessor.TrackSubscriptions, null); } break; diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs index 938fdc626..056a5380a 100644 --- a/src/StackExchange.Redis/RedisDatabase.cs +++ b/src/StackExchange.Redis/RedisDatabase.cs @@ -1900,7 +1900,7 @@ public Task StringLongestCommonSubsequenceWithMatchesAsync(Redis public long Publish(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) { if (channel.IsNullOrEmpty) throw new ArgumentNullException(nameof(channel)); - var msg = Message.Create(-1, flags, channel.GetPublishCommand(), channel, message, multiplexer.ChannelPrefix); + var msg = Message.Create(-1, flags, channel.GetPublishCommand(), channel, message); // if we're actively subscribed: send via that connection (otherwise, follow normal rules) return ExecuteSync(msg, ResultProcessor.Int64, server: multiplexer.GetSubscribedServer(channel)); } @@ -1908,7 +1908,7 @@ public long Publish(RedisChannel channel, RedisValue message, CommandFlags flags public Task PublishAsync(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) { if (channel.IsNullOrEmpty) throw new ArgumentNullException(nameof(channel)); - var msg = Message.Create(-1, flags, channel.GetPublishCommand(), channel, message, multiplexer.ChannelPrefix); + var msg = Message.Create(-1, flags, channel.GetPublishCommand(), channel, message); // if we're actively subscribed: send via that connection (otherwise, follow normal rules) return ExecuteAsync(msg, ResultProcessor.Int64, server: multiplexer.GetSubscribedServer(channel)); } diff --git a/src/StackExchange.Redis/RedisServer.cs b/src/StackExchange.Redis/RedisServer.cs index c03ae921b..3bc306c69 100644 --- a/src/StackExchange.Redis/RedisServer.cs +++ b/src/StackExchange.Redis/RedisServer.cs @@ -541,14 +541,14 @@ public Task StringGetAsync(int db, RedisKey key, CommandFlags flags public RedisChannel[] SubscriptionChannels(RedisChannel pattern = default, CommandFlags flags = CommandFlags.None) { var msg = pattern.IsNullOrEmpty ? Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.CHANNELS) - : Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.CHANNELS, pattern, multiplexer.ChannelPrefix); + : Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.CHANNELS, pattern); return ExecuteSync(msg, ResultProcessor.RedisChannelArrayLiteral, defaultValue: Array.Empty()); } public Task SubscriptionChannelsAsync(RedisChannel pattern = default, CommandFlags flags = CommandFlags.None) { var msg = pattern.IsNullOrEmpty ? Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.CHANNELS) - : Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.CHANNELS, pattern, multiplexer.ChannelPrefix); + : Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.CHANNELS, pattern); return ExecuteAsync(msg, ResultProcessor.RedisChannelArrayLiteral, defaultValue: Array.Empty()); } @@ -566,13 +566,13 @@ public Task SubscriptionPatternCountAsync(CommandFlags flags = CommandFlag public long SubscriptionSubscriberCount(RedisChannel channel, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.NUMSUB, channel, multiplexer.ChannelPrefix); + var msg = Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.NUMSUB, channel); return ExecuteSync(msg, ResultProcessor.PubSubNumSub); } public Task SubscriptionSubscriberCountAsync(RedisChannel channel, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.NUMSUB, channel, multiplexer.ChannelPrefix); + var msg = Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.NUMSUB, channel); return ExecuteAsync(msg, ResultProcessor.PubSubNumSub); } diff --git a/src/StackExchange.Redis/RedisSubscriber.cs b/src/StackExchange.Redis/RedisSubscriber.cs index faeb172ff..824aef025 100644 --- a/src/StackExchange.Redis/RedisSubscriber.cs +++ b/src/StackExchange.Redis/RedisSubscriber.cs @@ -135,7 +135,7 @@ internal long EnsureSubscriptions(CommandFlags flags = CommandFlags.None) var subscriber = DefaultSubscriber; foreach (var pair in subscriptions) { - count += pair.Value.EnsureSubscribedToServer(subscriber, pair.Key, flags, true, this); + count += pair.Value.EnsureSubscribedToServer(subscriber, pair.Key, flags, true); } return count; } @@ -162,14 +162,14 @@ internal RedisSubscriber(ConnectionMultiplexer multiplexer, object? asyncState) public EndPoint? IdentifyEndpoint(RedisChannel channel, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.NUMSUB, channel, multiplexer.ChannelPrefix); + var msg = Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.NUMSUB, channel); msg.SetInternalCall(); return ExecuteSync(msg, ResultProcessor.ConnectionIdentity); } public Task IdentifyEndpointAsync(RedisChannel channel, CommandFlags flags = CommandFlags.None) { - var msg = Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.NUMSUB, channel, multiplexer.ChannelPrefix); + var msg = Message.Create(-1, flags, RedisCommand.PUBSUB, RedisLiterals.NUMSUB, channel); msg.SetInternalCall(); return ExecuteAsync(msg, ResultProcessor.ConnectionIdentity); } @@ -232,7 +232,7 @@ private static void ThrowIfNull(in RedisChannel channel) public long Publish(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) { ThrowIfNull(channel); - var msg = Message.Create(-1, flags, channel.GetPublishCommand(), channel, message, multiplexer.ChannelPrefix); + var msg = Message.Create(-1, flags, channel.GetPublishCommand(), channel, message); // if we're actively subscribed: send via that connection (otherwise, follow normal rules) return ExecuteSync(msg, ResultProcessor.Int64, server: multiplexer.GetSubscribedServer(channel)); } @@ -240,7 +240,7 @@ public long Publish(RedisChannel channel, RedisValue message, CommandFlags flags public Task PublishAsync(RedisChannel channel, RedisValue message, CommandFlags flags = CommandFlags.None) { ThrowIfNull(channel); - var msg = Message.Create(-1, flags, channel.GetPublishCommand(), channel, message, multiplexer.ChannelPrefix); + var msg = Message.Create(-1, flags, channel.GetPublishCommand(), channel, message); // if we're actively subscribed: send via that connection (otherwise, follow normal rules) return ExecuteAsync(msg, ResultProcessor.Int64, server: multiplexer.GetSubscribedServer(channel)); } @@ -262,7 +262,7 @@ private int Subscribe(RedisChannel channel, Action? ha var sub = multiplexer.GetOrAddSubscription(channel, flags); sub.Add(handler, queue); - return sub.EnsureSubscribedToServer(this, channel, flags, false, multiplexer); + return sub.EnsureSubscribedToServer(this, channel, flags, false); } internal void ResubscribeToServer(Subscription sub, RedisChannel channel, ServerEndPoint serverEndPoint, string cause) @@ -274,7 +274,7 @@ internal void ResubscribeToServer(Subscription sub, RedisChannel channel, Server { // we'll *try* for a simple resubscribe, following any -MOVED etc, but if that fails: fall back // to full reconfigure; importantly, note that we've already recorded the disconnect - var message = sub.GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, CommandFlags.None, false, multiplexer); + var message = sub.GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, CommandFlags.None, false); _ = ExecuteAsync(message, sub.Processor, serverEndPoint).ContinueWith( t => multiplexer.ReconfigureIfNeeded(serverEndPoint.EndPoint, false, cause: cause), TaskContinuationOptions.OnlyOnFaulted); @@ -305,7 +305,7 @@ private Task SubscribeAsync(RedisChannel channel, Action multiplexer.GetSubscribedServer(channel)?.EndPoint; @@ -319,7 +319,7 @@ public bool Unsubscribe(in RedisChannel channel, Action UnsubscribeAsync(in RedisChannel channel, Action.Default(asyncState); } @@ -371,7 +371,7 @@ public void UnsubscribeAll(CommandFlags flags = CommandFlags.None) if (subs.TryRemove(pair.Key, out var sub)) { sub.MarkCompleted(); - sub.UnsubscribeFromServer(this, pair.Key, flags, false, multiplexer); + sub.UnsubscribeFromServer(this, pair.Key, flags, false); } } } @@ -386,7 +386,7 @@ public Task UnsubscribeAllAsync(CommandFlags flags = CommandFlags.None) if (subs.TryRemove(pair.Key, out var sub)) { sub.MarkCompleted(); - last = sub.UnsubscribeFromServerAsync(this, pair.Key, flags, asyncState, false, multiplexer); + last = sub.UnsubscribeFromServerAsync(this, pair.Key, flags, asyncState, false); } } return last ?? CompletedTask.Default(asyncState); diff --git a/src/StackExchange.Redis/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs index 6762d28ec..f856a5b21 100644 --- a/src/StackExchange.Redis/ServerEndPoint.cs +++ b/src/StackExchange.Redis/ServerEndPoint.cs @@ -1078,7 +1078,7 @@ private async Task HandshakeAsync(PhysicalConnection connection, ILogger? log) var configChannel = Multiplexer.ConfigurationChangedChannel; if (configChannel != null) { - msg = Message.Create(-1, CommandFlags.FireAndForget, RedisCommand.SUBSCRIBE, RedisChannel.Literal(configChannel), Multiplexer.ChannelPrefix); + msg = Message.Create(-1, CommandFlags.FireAndForget, RedisCommand.SUBSCRIBE, RedisChannel.Literal(configChannel)); // Note: this is NOT internal, we want it to queue in a backlog for sending when ready if necessary await WriteDirectOrQueueFireAndForgetAsync(connection, msg, ResultProcessor.TrackSubscriptions).ForAwait(); } diff --git a/src/StackExchange.Redis/ServerSelectionStrategy.cs b/src/StackExchange.Redis/ServerSelectionStrategy.cs index 26f66f35b..243b41bc0 100644 --- a/src/StackExchange.Redis/ServerSelectionStrategy.cs +++ b/src/StackExchange.Redis/ServerSelectionStrategy.cs @@ -101,30 +101,26 @@ public int HashSlot(in RedisKey key) /// /// The to determine a slot ID for. public int HashSlot(in RedisChannel channel) - // note that the RedisChannel->byte[] converter is always direct, so this is not an alloc - // (we deal with channels far less frequently, so pay the encoding cost up-front) - => ServerType == ServerType.Standalone || channel.IsNull ? NoSlot : GetClusterSlot(channel.RoutingSpan); - - internal int HashSlot(byte[] prefix, in RedisChannel channel) { if (ServerType == ServerType.Standalone || channel.IsNull) return NoSlot; - return prefix.Length == 0 | channel.IgnoreChannelPrefix - ? GetClusterSlot(channel.RoutingSpan) - : WithPrefix(prefix, channel); + ReadOnlySpan routingSpan = channel.RoutingSpan; + byte[] prefix; + return channel.IgnoreChannelPrefix || (prefix = multiplexer.ChannelPrefix).Length == 0 + ? GetClusterSlot(routingSpan) : GetClusterSlotWithPrefix(prefix, routingSpan); - static int WithPrefix(byte[] prefix, in RedisChannel channel) + static int GetClusterSlotWithPrefix(byte[] prefixRaw, ReadOnlySpan routingSpan) { + ReadOnlySpan prefixSpan = prefixRaw; const int MAX_STACK = 128; byte[]? lease = null; - var routingSpan = channel.RoutingSpan; - var totalLength = prefix.Length + routingSpan.Length; + var totalLength = prefixSpan.Length + routingSpan.Length; var span = totalLength <= MAX_STACK ? stackalloc byte[MAX_STACK] : (lease = ArrayPool.Shared.Rent(totalLength)); - prefix.CopyTo(span); - routingSpan.CopyTo(span.Slice(prefix.Length)); + prefixSpan.CopyTo(span); + routingSpan.CopyTo(span.Slice(prefixSpan.Length)); var result = GetClusterSlot(span.Slice(0, totalLength)); if (lease is not null) ArrayPool.Shared.Return(lease); return result; diff --git a/src/StackExchange.Redis/Subscription.cs b/src/StackExchange.Redis/Subscription.cs index 87e981a48..987227da5 100644 --- a/src/StackExchange.Redis/Subscription.cs +++ b/src/StackExchange.Redis/Subscription.cs @@ -38,8 +38,7 @@ internal abstract int EnsureSubscribedToServer( RedisSubscriber subscriber, in RedisChannel channel, CommandFlags flags, - bool internalCall, - ConnectionMultiplexer multiplexer); + bool internalCall); // returns the number of changes required internal abstract Task EnsureSubscribedToServerAsync( @@ -47,23 +46,20 @@ internal abstract Task EnsureSubscribedToServerAsync( RedisChannel channel, CommandFlags flags, bool internalCall, - ConnectionMultiplexer multiplexer, ServerEndPoint? server = null); internal abstract bool UnsubscribeFromServer( RedisSubscriber subscriber, in RedisChannel channel, CommandFlags flags, - bool internalCall, - ConnectionMultiplexer multiplexer); + bool internalCall); internal abstract Task UnsubscribeFromServerAsync( RedisSubscriber subscriber, RedisChannel channel, CommandFlags flags, object? asyncState, - bool internalCall, - ConnectionMultiplexer multiplexer); + bool internalCall); internal abstract int GetConnectionCount(); @@ -82,8 +78,7 @@ internal Message GetSubscriptionMessage( RedisChannel channel, SubscriptionAction action, CommandFlags flags, - bool internalCall, - ConnectionMultiplexer multiplexer) + bool internalCall) { const RedisChannel.RedisChannelOptions OPTIONS_MASK = ~( RedisChannel.RedisChannelOptions.KeyRouted | RedisChannel.RedisChannelOptions.IgnoreChannelPrefix); @@ -114,7 +109,7 @@ internal Message GetSubscriptionMessage( }; // TODO: Consider flags here - we need to pass Fire and Forget, but don't want to intermingle Primary/Replica - var msg = Message.Create(-1, Flags | flags, command, channel, multiplexer.ChannelPrefix); + var msg = Message.Create(-1, Flags | flags, command, channel); msg.SetForSubscriptionBridge(); if (internalCall) { @@ -229,13 +224,12 @@ internal override bool UnsubscribeFromServer( RedisSubscriber subscriber, in RedisChannel channel, CommandFlags flags, - bool internalCall, - ConnectionMultiplexer multiplexer) + bool internalCall) { var server = _currentServer; if (server is not null) { - var message = GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall, multiplexer); + var message = GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall); return subscriber.multiplexer.ExecuteSyncImpl(message, Processor, server); } @@ -247,13 +241,12 @@ internal override Task UnsubscribeFromServerAsync( RedisChannel channel, CommandFlags flags, object? asyncState, - bool internalCall, - ConnectionMultiplexer multiplexer) + bool internalCall) { var server = _currentServer; if (server is not null) { - var message = GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall, multiplexer); + var message = GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall); return subscriber.multiplexer.ExecuteAsyncImpl(message, Processor, asyncState, server); } @@ -281,14 +274,13 @@ internal override int EnsureSubscribedToServer( RedisSubscriber subscriber, in RedisChannel channel, CommandFlags flags, - bool internalCall, - ConnectionMultiplexer multiplexer) + bool internalCall) { if (IsConnectedAny()) return 0; // we're not appropriately connected, so blank it out for eligible reconnection _currentServer = null; - var message = GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall, multiplexer); + var message = GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall); var selected = subscriber.multiplexer.SelectServer(message); _ = subscriber.ExecuteSync(message, Processor, selected); return 1; @@ -299,14 +291,13 @@ internal override async Task EnsureSubscribedToServerAsync( RedisChannel channel, CommandFlags flags, bool internalCall, - ConnectionMultiplexer multiplexer, ServerEndPoint? server = null) { if (IsConnectedAny()) return 0; // we're not appropriately connected, so blank it out for eligible reconnection _currentServer = null; - var message = GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall, multiplexer); + var message = GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall); server ??= subscriber.multiplexer.SelectServer(message); await subscriber.ExecuteAsync(message, Processor, server).ForAwait(); return 1; @@ -413,8 +404,7 @@ internal override int EnsureSubscribedToServer( RedisSubscriber subscriber, in RedisChannel channel, CommandFlags flags, - bool internalCall, - ConnectionMultiplexer multiplexer) + bool internalCall) { int delta = 0; var muxer = subscriber.multiplexer; @@ -426,7 +416,7 @@ internal override int EnsureSubscribedToServer( { if (!IsConnectedTo(server.EndPoint)) { - var message = GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall, multiplexer); + var message = GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall); subscriber.ExecuteSync(message, Processor, server); delta++; } @@ -441,7 +431,6 @@ internal override async Task EnsureSubscribedToServerAsync( RedisChannel channel, CommandFlags flags, bool internalCall, - ConnectionMultiplexer multiplexer, ServerEndPoint? server = null) { int delta = 0; @@ -459,7 +448,7 @@ internal override async Task EnsureSubscribedToServerAsync( { if (!IsConnectedTo(loopServer.EndPoint)) { - var message = GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall, multiplexer); + var message = GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall); await subscriber.ExecuteAsync(message, Processor, loopServer).ForAwait(); delta++; } @@ -474,13 +463,12 @@ internal override bool UnsubscribeFromServer( RedisSubscriber subscriber, in RedisChannel channel, CommandFlags flags, - bool internalCall, - ConnectionMultiplexer multiplexer) + bool internalCall) { bool any = false; foreach (var server in _servers) { - var message = GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall, multiplexer); + var message = GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall); any |= subscriber.ExecuteSync(message, Processor, server.Value); } @@ -492,13 +480,12 @@ internal override async Task UnsubscribeFromServerAsync( RedisChannel channel, CommandFlags flags, object? asyncState, - bool internalCall, - ConnectionMultiplexer multiplexer) + bool internalCall) { bool any = false; foreach (var server in _servers) { - var message = GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall, multiplexer); + var message = GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags, internalCall); any |= await subscriber.ExecuteAsync(message, Processor, server.Value).ForAwait(); } From 33f715f0340117a376a59343a5aed10435d30ab5 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 2 Feb 2026 15:21:16 +0000 Subject: [PATCH 31/37] - reconnect RESP3 channel subscriptions - EnsureSubscribedToServer[Async] can now *remove* subscriptions in the multi-node case --- src/StackExchange.Redis/ServerEndPoint.cs | 5 +-- src/StackExchange.Redis/Subscription.cs | 44 +++++++++++++---------- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/StackExchange.Redis/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs index f856a5b21..abe8d8afb 100644 --- a/src/StackExchange.Redis/ServerEndPoint.cs +++ b/src/StackExchange.Redis/ServerEndPoint.cs @@ -695,14 +695,15 @@ internal void OnFullyEstablished(PhysicalConnection connection, string source) // Clear the unselectable flag ASAP since we are open for business ClearUnselectable(UnselectableFlags.DidNotRespond); - if (bridge == subscription) + bool isResp3 = KnowOrAssumeResp3(); + if (bridge == subscription || isResp3) { // Note: this MUST be fire and forget, because we might be in the middle of a Sync processing // TracerProcessor which is executing this line inside a SetResultCore(). // Since we're issuing commands inside a SetResult path in a message, we'd create a deadlock by waiting. Multiplexer.EnsureSubscriptions(CommandFlags.FireAndForget); } - if (IsConnected && (IsSubscriberConnected || !SupportsSubscriptions || KnowOrAssumeResp3())) + if (IsConnected && (IsSubscriberConnected || !SupportsSubscriptions || isResp3)) { // Only connect on the second leg - we can accomplish this by checking both // Or the first leg, if we're only making 1 connection because subscriptions aren't supported diff --git a/src/StackExchange.Redis/Subscription.cs b/src/StackExchange.Redis/Subscription.cs index 987227da5..07501d89d 100644 --- a/src/StackExchange.Redis/Subscription.cs +++ b/src/StackExchange.Redis/Subscription.cs @@ -410,22 +410,31 @@ internal override int EnsureSubscribedToServer( var muxer = subscriber.multiplexer; foreach (var server in muxer.GetServerSnapshot()) { - // exclude sentinel, and only use replicas if we're explicitly asking for them - bool useReplica = (Flags & CommandFlags.DemandReplica) != 0; - if (server.ServerType != ServerType.Sentinel & server.IsReplica == useReplica) + var change = GetSubscriptionChange(server, flags); + if (change is not null) { - if (!IsConnectedTo(server.EndPoint)) - { - var message = GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall); - subscriber.ExecuteSync(message, Processor, server); - delta++; - } + // make it so + var message = GetSubscriptionMessage(channel, change.GetValueOrDefault(), flags, internalCall); + subscriber.ExecuteSync(message, Processor, server); + delta++; } } return delta; } + private SubscriptionAction? GetSubscriptionChange(ServerEndPoint server, CommandFlags flags) + { + // exclude sentinel, and only use replicas if we're explicitly asking for them + bool useReplica = (Flags & CommandFlags.DemandReplica) != 0; + bool shouldBeConnected = server.ServerType != ServerType.Sentinel & server.IsReplica == useReplica; + if (shouldBeConnected == IsConnectedTo(server.EndPoint)) + { + return null; + } + return shouldBeConnected ? SubscriptionAction.Subscribe : SubscriptionAction.Unsubscribe; + } + internal override async Task EnsureSubscribedToServerAsync( RedisSubscriber subscriber, RedisChannel channel, @@ -440,18 +449,15 @@ internal override async Task EnsureSubscribedToServerAsync( for (int i = 0; i < len; i++) { var loopServer = snapshot.Span[i]; // spans and async do not mix well - if (server is null || server == loopServer) + if (server is null || server == loopServer) // either "all" or "just the one we passed in" { - // exclude sentinel, and only use replicas if we're explicitly asking for them - bool useReplica = (Flags & CommandFlags.DemandReplica) != 0; - if (loopServer.ServerType != ServerType.Sentinel & loopServer.IsReplica == useReplica) + var change = GetSubscriptionChange(loopServer, flags); + if (change is not null) { - if (!IsConnectedTo(loopServer.EndPoint)) - { - var message = GetSubscriptionMessage(channel, SubscriptionAction.Subscribe, flags, internalCall); - await subscriber.ExecuteAsync(message, Processor, loopServer).ForAwait(); - delta++; - } + // make it so + var message = GetSubscriptionMessage(channel, change.GetValueOrDefault(), flags, internalCall); + await subscriber.ExecuteAsync(message, Processor, loopServer).ForAwait(); + delta++; } } } From 81d80b0cff80229d53fe55885181097908216bd8 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 2 Feb 2026 15:41:22 +0000 Subject: [PATCH 32/37] runner note --- tests/StackExchange.Redis.Tests/Issues/Issue2507.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/StackExchange.Redis.Tests/Issues/Issue2507.cs b/tests/StackExchange.Redis.Tests/Issues/Issue2507.cs index b548d7031..f77e43e29 100644 --- a/tests/StackExchange.Redis.Tests/Issues/Issue2507.cs +++ b/tests/StackExchange.Redis.Tests/Issues/Issue2507.cs @@ -7,7 +7,7 @@ namespace StackExchange.Redis.Tests.Issues; [Collection(NonParallelCollection.Name)] public class Issue2507(ITestOutputHelper output, SharedConnectionFixture? fixture = null) : TestBase(output, fixture) { - [Fact(Explicit = true)] + [Fact(Explicit = true)] // note this may show as Inconclusive, depending on the runner public async Task Execute() { await using var conn = Create(shared: false); From 11f4af12dee76535404363ce6f30670a0689f07d Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 2 Feb 2026 16:05:35 +0000 Subject: [PATCH 33/37] make SubscriptionsSurviveConnectionFailureAsync more reliable; Ping is not routed to the channel, and can use primary or replica, so Ping is not a reliable test *unless* we demand master --- tests/StackExchange.Redis.Tests/FailoverTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/StackExchange.Redis.Tests/FailoverTests.cs b/tests/StackExchange.Redis.Tests/FailoverTests.cs index 33b24f16f..825c8efce 100644 --- a/tests/StackExchange.Redis.Tests/FailoverTests.cs +++ b/tests/StackExchange.Redis.Tests/FailoverTests.cs @@ -236,10 +236,12 @@ public async Task SubscriptionsSurviveConnectionFailureAsync() server.SimulateConnectionFailure(SimulatedFailureType.All); // Trigger failure (RedisTimeoutException or RedisConnectionException because // of backlog behavior) - var ex = Assert.ThrowsAny(() => sub.Ping()); - Assert.True(ex is RedisTimeoutException or RedisConnectionException); Assert.False(sub.IsConnected(channel)); + var ex = Assert.ThrowsAny(() => Log($"Ping: {sub.Ping(CommandFlags.DemandMaster)}ms")); + Assert.True(ex is RedisTimeoutException or RedisConnectionException); + Log($"Failed as expected: {ex.Message}"); + // Now reconnect... conn.AllowConnect = true; Log("Waiting on reconnect"); From 1d9a71a3d88f39a61cf300b8d27b2c11a62d0ac5 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 2 Feb 2026 16:12:07 +0000 Subject: [PATCH 34/37] rem net8.0 tests --- .../StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj b/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj index f09780f7a..e02a6ac36 100644 --- a/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj +++ b/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj @@ -1,6 +1,7 @@  - net481;net8.0;net10.0 + + net481;net10.0 Exe StackExchange.Redis.Tests true From 864c77417b9a87df56983407016586746d526476 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 2 Feb 2026 16:58:47 +0000 Subject: [PATCH 35/37] - allow single-node subscriptions to follow relocations - prevent concurrency problem on TextWriterOutputHelper --- .../Helpers/TextWriterOutputHelper.cs | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/tests/StackExchange.Redis.Tests/Helpers/TextWriterOutputHelper.cs b/tests/StackExchange.Redis.Tests/Helpers/TextWriterOutputHelper.cs index e41a46670..2a23f3246 100644 --- a/tests/StackExchange.Redis.Tests/Helpers/TextWriterOutputHelper.cs +++ b/tests/StackExchange.Redis.Tests/Helpers/TextWriterOutputHelper.cs @@ -7,7 +7,7 @@ namespace StackExchange.Redis.Tests.Helpers; public class TextWriterOutputHelper(ITestOutputHelper outputHelper) : TextWriter { - private StringBuilder Buffer { get; } = new StringBuilder(2048); + private readonly StringBuilder _buffer = new(2048); private StringBuilder? Echo { get; set; } public override Encoding Encoding => Encoding.UTF8; private readonly ITestOutputHelper Output = outputHelper; @@ -37,7 +37,10 @@ public override void WriteLine(string? value) try { - base.WriteLine(value); + lock (_buffer) // keep everything together + { + base.WriteLine(value); + } } catch (Exception ex) { @@ -49,32 +52,44 @@ public override void WriteLine(string? value) public override void Write(char value) { - if (value == '\n' || value == '\r') + lock (_buffer) { - // Ignore empty lines - if (Buffer.Length > 0) + if (value == '\n' || value == '\r') { - FlushBuffer(); + // Ignore empty lines + if (_buffer.Length > 0) + { + FlushBuffer(); + } + } + else + { + _buffer.Append(value); } - } - else - { - Buffer.Append(value); } } protected override void Dispose(bool disposing) { - if (Buffer.Length > 0) + lock (_buffer) { - FlushBuffer(); + if (_buffer.Length > 0) + { + FlushBuffer(); + } } + base.Dispose(disposing); } private void FlushBuffer() { - var text = Buffer.ToString(); + string text; + lock (_buffer) + { + text = _buffer.ToString(); + _buffer.Clear(); + } try { Output.WriteLine(text); @@ -84,6 +99,5 @@ private void FlushBuffer() // Thrown when writing from a handler after a test has ended - just bail in this case } Echo?.AppendLine(text); - Buffer.Clear(); } } From 6019a52e17a5546269d92f526ab376455a9ef14d Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 3 Feb 2026 10:37:24 +0000 Subject: [PATCH 36/37] improve subscription recovery logic when using key-routed subscriptions --- src/StackExchange.Redis/PhysicalConnection.cs | 2 +- src/StackExchange.Redis/RedisSubscriber.cs | 2 +- .../ServerSelectionStrategy.cs | 20 +++++ src/StackExchange.Redis/Subscription.cs | 21 ++++- .../ClusterShardedTests.cs | 90 ++++++++++++------- 5 files changed, 102 insertions(+), 33 deletions(-) diff --git a/src/StackExchange.Redis/PhysicalConnection.cs b/src/StackExchange.Redis/PhysicalConnection.cs index b5a60d995..857902f48 100644 --- a/src/StackExchange.Redis/PhysicalConnection.cs +++ b/src/StackExchange.Redis/PhysicalConnection.cs @@ -1999,7 +1999,7 @@ static bool TryGetMultiPubSubPayload(in RawResult value, out Sequence } } - private bool PeekChannelMessage(RedisCommand command, RedisChannel channel) + private bool PeekChannelMessage(RedisCommand command, in RedisChannel channel) { Message? msg; bool haveMsg; diff --git a/src/StackExchange.Redis/RedisSubscriber.cs b/src/StackExchange.Redis/RedisSubscriber.cs index 824aef025..ca66e6113 100644 --- a/src/StackExchange.Redis/RedisSubscriber.cs +++ b/src/StackExchange.Redis/RedisSubscriber.cs @@ -265,7 +265,7 @@ private int Subscribe(RedisChannel channel, Action? ha return sub.EnsureSubscribedToServer(this, channel, flags, false); } - internal void ResubscribeToServer(Subscription sub, RedisChannel channel, ServerEndPoint serverEndPoint, string cause) + internal void ResubscribeToServer(Subscription sub, in RedisChannel channel, ServerEndPoint serverEndPoint, string cause) { // conditional: only if that's the server we were connected to, or "none"; we don't want to end up duplicated if (sub.TryRemoveEndpoint(serverEndPoint) || !sub.IsConnectedAny()) diff --git a/src/StackExchange.Redis/ServerSelectionStrategy.cs b/src/StackExchange.Redis/ServerSelectionStrategy.cs index 243b41bc0..db729ba26 100644 --- a/src/StackExchange.Redis/ServerSelectionStrategy.cs +++ b/src/StackExchange.Redis/ServerSelectionStrategy.cs @@ -382,5 +382,25 @@ private ServerEndPoint[] MapForMutation() } return Any(command, flags, allowDisconnected); } + + internal bool CanServeSlot(ServerEndPoint server, in RedisChannel channel) + => CanServeSlot(server, HashSlot(in channel)); + + internal bool CanServeSlot(ServerEndPoint server, int slot) + { + if (slot == NoSlot) return true; + var arr = map; + if (arr is null) return true; // means "any" + + var primary = arr[slot]; + if (server == primary) return true; + + var replicas = primary.Replicas; + for (int i = 0; i < replicas.Length; i++) + { + if (server == replicas[i]) return true; + } + return false; + } } } diff --git a/src/StackExchange.Redis/Subscription.cs b/src/StackExchange.Redis/Subscription.cs index 07501d89d..99f3d00cb 100644 --- a/src/StackExchange.Redis/Subscription.cs +++ b/src/StackExchange.Redis/Subscription.cs @@ -75,7 +75,7 @@ public Subscription(CommandFlags flags) /// Gets the configured (P)SUBSCRIBE or (P)UNSUBSCRIBE for an action. /// internal Message GetSubscriptionMessage( - RedisChannel channel, + in RedisChannel channel, SubscriptionAction action, CommandFlags flags, bool internalCall) @@ -276,6 +276,7 @@ internal override int EnsureSubscribedToServer( CommandFlags flags, bool internalCall) { + RemoveIncorrectRouting(subscriber, in channel, flags, internalCall); if (IsConnectedAny()) return 0; // we're not appropriately connected, so blank it out for eligible reconnection @@ -286,6 +287,23 @@ internal override int EnsureSubscribedToServer( return 1; } + private void RemoveIncorrectRouting(RedisSubscriber subscriber, in RedisChannel channel, CommandFlags flags, bool internalCall) + { + // only applies to cluster, when using key-routed channels (sharded, explicit key-routed, or + // a single-key keyspace notification); is the subscribed server still handling that channel? + if (channel.IsKeyRouted && _currentServer is { ServerType: ServerType.Cluster } current) + { + // if we consider replicas, there can be multiple valid target servers; we can't ask + // "is this the correct server?", but we can ask "is it suitable?", based on the slot + if (!subscriber.multiplexer.ServerSelectionStrategy.CanServeSlot(_currentServer, channel)) + { + var message = GetSubscriptionMessage(channel, SubscriptionAction.Unsubscribe, flags | CommandFlags.FireAndForget, internalCall); + subscriber.multiplexer.ExecuteSyncImpl(message, Processor, current); + _currentServer = null; // pre-emptively disconnect - F+F + } + } + } + internal override async Task EnsureSubscribedToServerAsync( RedisSubscriber subscriber, RedisChannel channel, @@ -293,6 +311,7 @@ internal override async Task EnsureSubscribedToServerAsync( bool internalCall, ServerEndPoint? server = null) { + RemoveIncorrectRouting(subscriber, in channel, flags, internalCall); if (IsConnectedAny()) return 0; // we're not appropriately connected, so blank it out for eligible reconnection diff --git a/tests/StackExchange.Redis.Tests/ClusterShardedTests.cs b/tests/StackExchange.Redis.Tests/ClusterShardedTests.cs index 8af0a1c7b..c9101fb08 100644 --- a/tests/StackExchange.Redis.Tests/ClusterShardedTests.cs +++ b/tests/StackExchange.Redis.Tests/ClusterShardedTests.cs @@ -72,7 +72,8 @@ public async Task TestShardedPubsubSubscriberAgainstReconnects() public async Task TestShardedPubsubSubscriberAgainsHashSlotMigration() { Skip.UnlessLongRunning(); - var channel = RedisChannel.Sharded(Me()); + var channel = RedisChannel.Sharded(Me()); // invent a channel that will use SSUBSCRIBE + var key = (RedisKey)(byte[])channel!; // use the same value as a key, to test keyspace notifications via a single-key API await using var conn = Create(allowAdmin: true, keepAlive: 1, connectTimeout: 3000, shared: false, require: RedisFeatures.v7_0_0_rc1); Assert.True(conn.IsConnected); var db = conn.GetDatabase(); @@ -80,46 +81,75 @@ public async Task TestShardedPubsubSubscriberAgainsHashSlotMigration() await Task.Delay(50); // let the sub settle (this isn't needed on RESP3, note) var pubsub = conn.GetSubscriber(); - List<(RedisChannel, RedisValue)> received = []; - var queue = await pubsub.SubscribeAsync(channel); - _ = Task.Run(async () => + var keynotify = RedisChannel.KeySpaceSingleKey(key, db.Database); + Assert.False(keynotify.IsSharded); // keyspace notifications do not use SSUBSCRIBE; this matters, because it means we don't get nuked when the slot migrates + Assert.False(keynotify.IsMultiNode); // we specificially want this *not* to be multi-node; we want to test that it follows the key correctly + + int keynotificationCount = 0; + await pubsub.SubscribeAsync(keynotify, (_, _) => Interlocked.Increment(ref keynotificationCount)); + try { - // use queue API to have control over order - await foreach (var item in queue) + List<(RedisChannel, RedisValue)> received = []; + var queue = await pubsub.SubscribeAsync(channel); + _ = Task.Run(async () => { - lock (received) + // use queue API to have control over order + await foreach (var item in queue) { - if (item.Channel.IsSharded && item.Channel == channel) received.Add((item.Channel, item.Message)); + lock (received) + { + if (item.Channel.IsSharded && item.Channel == channel) + received.Add((item.Channel, item.Message)); + } } + }); + Assert.Equal(2, conn.GetSubscriptionsCount()); + + await Task.Delay(50); // let the sub settle (this isn't needed on RESP3, note) + await db.PingAsync(); + + for (int i = 0; i < 5; i++) + { + // check we get a hit + Assert.Equal(1, await db.PublishAsync(channel, i.ToString())); + await db.StringIncrementAsync(key); } - }); - Assert.Equal(1, conn.GetSubscriptionsCount()); - await Task.Delay(50); // let the sub settle (this isn't needed on RESP3, note) - await db.PingAsync(); + await Task.Delay(50); // let the sub settle (this isn't needed on RESP3, note) - for (int i = 0; i < 5; i++) - { - // check we get a hit - Assert.Equal(1, await db.PublishAsync(channel, i.ToString())); - } - await Task.Delay(50); // let the sub settle (this isn't needed on RESP3, note) + // lets migrate the slot for "testShardChannel" to another node + await DoHashSlotMigrationAsync(); + + await Task.Delay(4000); + for (int i = 0; i < 5; i++) + { + // check we get a hit + Assert.Equal(1, await db.PublishAsync(channel, i.ToString())); + await db.StringIncrementAsync(key); + } - // lets migrate the slot for "testShardChannel" to another node - await DoHashSlotMigrationAsync(); + await Task.Delay(50); // let the sub settle (this isn't needed on RESP3, note) - await Task.Delay(4000); - for (int i = 0; i < 5; i++) + Assert.Equal(2, conn.GetSubscriptionsCount()); + Assert.Equal(10, received.Count); + Assert.Equal(10, Volatile.Read(ref keynotificationCount)); + await RollbackHashSlotMigrationAsync(); + ClearAmbientFailures(); + } + finally { - // check we get a hit - Assert.Equal(1, await db.PublishAsync(channel, i.ToString())); + try + { + // ReSharper disable once MethodHasAsyncOverload - F+F + await pubsub.UnsubscribeAsync(keynotify, flags: CommandFlags.FireAndForget); + await pubsub.UnsubscribeAsync(channel, flags: CommandFlags.FireAndForget); + Log("Channels unsubscribed."); + } + catch (Exception ex) + { + Log($"Error while unsubscribing: {ex.Message}"); + } } - await Task.Delay(50); // let the sub settle (this isn't needed on RESP3, note) - - Assert.Equal(1, conn.GetSubscriptionsCount()); - Assert.Equal(10, received.Count); - await RollbackHashSlotMigrationAsync(); - ClearAmbientFailures(); } private Task DoHashSlotMigrationAsync() => MigrateSlotForTestShardChannelAsync(false); From d213dd764942a409d2a95ea141a0c94e05afdd59 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 4 Feb 2026 13:10:46 +0000 Subject: [PATCH 37/37] docs typo --- docs/KeyspaceNotifications.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/KeyspaceNotifications.md b/docs/KeyspaceNotifications.md index 3cb37920d..eeb156632 100644 --- a/docs/KeyspaceNotifications.md +++ b/docs/KeyspaceNotifications.md @@ -184,7 +184,7 @@ sub.SubscribeAsync(channel, (recvChannel, recvValue) => { // by including prefix in the TryParse, we filter out notifications that are not for this client // *and* the key is sliced internally to remove this prefix when reading - if (KeyNotification.TryParse(prefix, recvChannel, recvValue, out var notification)) + if (KeyNotification.TryParse(keyPrefix, recvChannel, recvValue, out var notification)) { // if we get here, the key prefix was a match var key = notification.GetKey(); // "order/123" - note no prefix