From e91bfe533f46f5c5a3e7586f5a9080ec41b92b8a Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 23 Apr 2026 09:06:54 +0100 Subject: [PATCH 01/14] inital stab at subkey notifications --- src/StackExchange.Redis/KeyNotification.cs | 501 +++++++++++++++++- .../KeyNotificationType.cs | 5 +- .../KeyNotificationTypeMetadata.cs | 1 + .../PublicAPI/PublicAPI.Unshipped.txt | 21 + src/StackExchange.Redis/RedisChannel.cs | 75 ++- .../KeyNotificationTests.cs | 342 ++++++++++++ 6 files changed, 916 insertions(+), 29 deletions(-) diff --git a/src/StackExchange.Redis/KeyNotification.cs b/src/StackExchange.Redis/KeyNotification.cs index d435c9382..cd680db35 100644 --- a/src/StackExchange.Redis/KeyNotification.cs +++ b/src/StackExchange.Redis/KeyNotification.cs @@ -1,12 +1,59 @@ using System; using System.Buffers; using System.Buffers.Text; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Text; using RESPite; using static StackExchange.Redis.KeyNotificationChannels; + namespace StackExchange.Redis; +/// +/// Represents the type of keyspace notification channel. +/// +public enum KeyNotificationKind +{ + /// + /// Unknown or invalid notification type. + /// + Unknown = 0, + + /// + /// Standard keyspace notification: __keyspace@{db}__:{key} with payload containing the event. + /// + KeySpace = 1, + + /// + /// Standard keyevent notification: __keyevent@{db}__:{event} with payload containing the key. + /// + KeyEvent = 2, + + /// + /// Subkey keyspace notification: __subkeyspace@{db}__:{key} with payload containing event|subkey. + /// + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + SubKeySpace = 3, + + /// + /// Subkey keyevent notification: __subkeyevent@{db}__:{event} with payload containing key|subkey. + /// + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + SubKeyEvent = 4, + + /// + /// Subkey keyspaceitem notification: __subkeyspaceitem@{db}__:{key}\n{subkey} with payload containing the event. + /// + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + SubKeySpaceItem = 5, + + /// + /// Subkey keyspaceevent notification: __subkeyspaceevent@{db}__:{event}|{key} with payload containing the subkey. + /// + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + SubKeySpaceEvent = 6, +} + /// /// 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 @@ -19,36 +66,108 @@ public readonly ref struct KeyNotification private readonly RedisChannel _channel; private readonly RedisValue _value; private readonly int _keyOffset; // used to efficiently strip key prefixes + private readonly KeyNotificationKind _kind; // the type of notification // 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, resolve the key and event type. + /// Gets the kind of keyspace notification this represents. + /// + public KeyNotificationKind Kind => _kind; + + /// + /// If the channel is a keyspace, keyevent, subkeyspace, subkeyevent, subkeyspaceitem, or subkeyeventitem notification, resolve the key and event type. /// public static bool TryParse(scoped in RedisChannel channel, scoped in RedisValue value, out KeyNotification notification) { // validate that it looks reasonable var span = channel.Span; - // KeySpaceStart and KeyEventStart are the same size, see KeyEventPrefix_KeySpacePrefix_Length_Matches + // Check for SubKeySpaceEvent prefix first (it's the longest: 20 chars) + if (span.Length >= SubKeySpaceEventPrefix.Length + MinSuffixBytes) + { + var prefix = span.Slice(0, SubKeySpaceEventPrefix.Length); + var hashCS = AsciiHash.HashCS(prefix); + if (SubKeySpaceEventPrefix.IsCS(prefix, hashCS)) + { + // __subkeyspaceevent@__:| - check for __: followed by something + if (span.Slice(SubKeySpaceEventPrefix.Length).IndexOf("__:"u8) > 0) + { + notification = new KeyNotification(in channel, in value, KeyNotificationKind.SubKeySpaceEvent); + return true; + } + } + } + + // Check for SubKeySpaceItem prefix (19 chars) + if (span.Length >= SubKeySpaceItemPrefix.Length + MinSuffixBytes) + { + var prefix = span.Slice(0, SubKeySpaceItemPrefix.Length); + var hashCS = AsciiHash.HashCS(prefix); + if (SubKeySpaceItemPrefix.IsCS(prefix, hashCS)) + { + // __subkeyspaceitem@__:\n - check for __: followed by something + if (span.Slice(SubKeySpaceItemPrefix.Length).IndexOf("__:"u8) > 0) + { + notification = new KeyNotification(in channel, in value, KeyNotificationKind.SubKeySpaceItem); + return true; + } + } + } + + // Check for the subkey prefixes (14 chars: __subkeyspace@, __subkeyevent@) + if (span.Length >= SubKeySpacePrefix.Length + MinSuffixBytes) + { + var prefix = span.Slice(0, SubKeySpacePrefix.Length); + var hashCS = AsciiHash.HashCS(prefix); + switch (hashCS) + { + case SubKeySpacePrefix.HashCS when SubKeySpacePrefix.IsCS(prefix, hashCS): + // check that there is *something* non-empty after the prefix, with __: as the suffix + if (span.Slice(SubKeySpacePrefix.Length).IndexOf("__:"u8) > 0) + { + notification = new KeyNotification(in channel, in value, KeyNotificationKind.SubKeySpace); + return true; + } + break; + + case SubKeyEventPrefix.HashCS when SubKeyEventPrefix.IsCS(prefix, hashCS): + // check that there is *something* non-empty after the prefix, with __: as the suffix + if (span.Slice(SubKeySpacePrefix.Length).IndexOf("__:"u8) > 0) + { + notification = new KeyNotification(in channel, in value, KeyNotificationKind.SubKeyEvent); + return true; + } + break; + } + } + + // Check for basic keyspace/keyevent prefixes (11 chars: __keyspace@, __keyevent@) if (span.Length >= KeySpacePrefix.Length + MinSuffixBytes) { - // check that the prefix is valid, i.e. "__keyspace@" or "__keyevent@" + // check that the prefix is valid, i.e. "__keyspace@", "__keyevent@" var prefix = span.Slice(0, KeySpacePrefix.Length); var hashCS = AsciiHash.HashCS(prefix); switch (hashCS) { case KeyEventPrefix.HashCS when KeyEventPrefix.IsCS(prefix, hashCS): - case KeySpacePrefix.HashCS when KeySpacePrefix.IsCS(prefix, hashCS): // check that there is *something* non-empty after the prefix, with __: as the suffix (we don't verify *what*) if (span.Slice(KeySpacePrefix.Length).IndexOf("__:"u8) > 0) { - notification = new KeyNotification(in channel, in value); + notification = new KeyNotification(in channel, in value, KeyNotificationKind.KeyEvent); return true; } + break; + case KeySpacePrefix.HashCS when KeySpacePrefix.IsCS(prefix, hashCS): + // check that there is *something* non-empty after the prefix, with __: as the suffix (we don't verify *what*) + if (span.Slice(KeySpacePrefix.Length).IndexOf("__:"u8) > 0) + { + notification = new KeyNotification(in channel, in value, KeyNotificationKind.KeySpace); + return true; + } break; } } @@ -92,11 +211,12 @@ internal KeyNotification WithKeySlice(int keyPrefixLength) /// public RedisValue GetValue() => _value; - internal KeyNotification(scoped in RedisChannel channel, scoped in RedisValue value) + internal KeyNotification(scoped in RedisChannel channel, scoped in RedisValue value, KeyNotificationKind kind) { _channel = channel; _value = value; _keyOffset = 0; + _kind = kind; } internal int KeyOffset => _keyOffset; @@ -110,7 +230,32 @@ 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(KeySpacePrefix.Length); // also works for KeyEventPrefix + + // Determine the prefix length based on the channel type + var fullSpan = _channel.Span; + int prefixLength; + if (fullSpan.Length >= SubKeySpaceEventPrefix.Length && + SubKeySpaceEventPrefix.IsCS(fullSpan.Slice(0, SubKeySpaceEventPrefix.Length), AsciiHash.HashCS(fullSpan))) + { + prefixLength = SubKeySpaceEventPrefix.Length; + } + else if (fullSpan.Length >= SubKeySpaceItemPrefix.Length && + SubKeySpaceItemPrefix.IsCS(fullSpan.Slice(0, SubKeySpaceItemPrefix.Length), AsciiHash.HashCS(fullSpan))) + { + prefixLength = SubKeySpaceItemPrefix.Length; + } + else if (fullSpan.Length >= SubKeySpacePrefix.Length && + (SubKeySpacePrefix.IsCS(fullSpan.Slice(0, SubKeySpacePrefix.Length), AsciiHash.HashCS(fullSpan)) || + SubKeyEventPrefix.IsCS(fullSpan.Slice(0, SubKeyEventPrefix.Length), AsciiHash.HashCS(fullSpan)))) + { + prefixLength = SubKeySpacePrefix.Length; + } + else + { + prefixLength = KeySpacePrefix.Length; // also works for KeyEventPrefix + } + + var span = fullSpan.Slice(prefixLength); var end = span.IndexOf((byte)'_'); // expecting "__:foo" - we'll just stop at the underscore if (end <= 0) return -1; @@ -144,9 +289,160 @@ public RedisKey GetKey() return blob; } + if (IsSubKeySpace) + { + // __subkeyspace@__: with payload |: + // channel contains the key + return ChannelSuffix.Slice(_keyOffset).ToArray(); + } + + if (IsSubKeyEvent) + { + // __subkeyevent@__: with payload :|: + // payload contains :|... + var value = ExtractLengthPrefixedValue(_value, _keyOffset); + if (value.IsNull) return RedisKey.Null; + // Convert RedisValue to RedisKey via byte array + byte[]? bytes = value; + return bytes; + } + + if (IsSubKeySpaceItem) + { + // __subkeyspaceitem@__:\n with payload + // channel contains key\nsubkey - extract just the key part + var suffix = ChannelSuffix; + var newlineIndex = suffix.IndexOf((byte)'\n'); + if (newlineIndex > 0) + { + return suffix.Slice(_keyOffset, newlineIndex - _keyOffset).ToArray(); + } + return RedisKey.Null; + } + + if (IsSubKeySpaceEvent) + { + // __subkeyspaceevent@__:| with payload : + // channel contains event|key - extract the key part after the pipe + var suffix = ChannelSuffix; + var pipeIndex = suffix.IndexOf((byte)'|'); + if (pipeIndex >= 0 && pipeIndex + 1 < suffix.Length) + { + return suffix.Slice(pipeIndex + 1 + _keyOffset).ToArray(); + } + return RedisKey.Null; + } + return RedisKey.Null; } + /// + /// The subkey associated with this event, if applicable. Returns for non-subkey notification types. + /// + /// For notifications with multiple subkeys, only the first subkey is returned. + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public RedisValue GetSubKey() + { + if (IsSubKeySpace) + { + // __subkeyspace@__: with payload |:[,...] + // payload contains |: + if (_value.TryGetSpan(out var span)) + { + var pipeIndex = span.IndexOf((byte)'|'); + if (pipeIndex >= 0 && pipeIndex + 1 < span.Length) + { + return ExtractLengthPrefixedValue(span.Slice(pipeIndex + 1)); + } + } + else + { + // Fallback for non-contiguous values + Span buffer = stackalloc byte[256]; + var bytesWritten = _value.CopyTo(buffer); + var pipeIndex = buffer.Slice(0, bytesWritten).IndexOf((byte)'|'); + if (pipeIndex >= 0 && pipeIndex + 1 < bytesWritten) + { + return ExtractLengthPrefixedValue(buffer.Slice(pipeIndex + 1, bytesWritten - pipeIndex - 1)); + } + } + } + + if (IsSubKeyEvent) + { + // __subkeyevent@__: with payload :|:[,...] + // payload contains :|: + if (_value.TryGetSpan(out var span)) + { + var pipeIndex = span.IndexOf((byte)'|'); + if (pipeIndex >= 0 && pipeIndex + 1 < span.Length) + { + return ExtractLengthPrefixedValue(span.Slice(pipeIndex + 1)); + } + } + else + { + // Fallback for non-contiguous values + Span buffer = stackalloc byte[256]; + var bytesWritten = _value.CopyTo(buffer); + var pipeIndex = buffer.Slice(0, bytesWritten).IndexOf((byte)'|'); + if (pipeIndex >= 0 && pipeIndex + 1 < bytesWritten) + { + return ExtractLengthPrefixedValue(buffer.Slice(pipeIndex + 1, bytesWritten - pipeIndex - 1)); + } + } + } + + if (IsSubKeySpaceItem) + { + // __subkeyspaceitem@__:\n with payload + // channel contains key\nsubkey + var suffix = ChannelSuffix; + var newlineIndex = suffix.IndexOf((byte)'\n'); + if (newlineIndex >= 0 && newlineIndex + 1 < suffix.Length) + { + return suffix.Slice(newlineIndex + 1).ToArray(); + } + } + + if (IsSubKeySpaceEvent) + { + // __subkeyspaceevent@__:| with payload :[,...] + // payload contains : + return ExtractLengthPrefixedValue(_value, 0); + } + + return RedisValue.Null; + } + + // Helper to extract a value prefixed with its length, e.g., "5:hello" -> "hello" + internal static RedisValue ExtractLengthPrefixedValue(in RedisValue value, int offset) + { + if (value.TryGetSpan(out var span)) + { + return ExtractLengthPrefixedValue(span.Slice(offset)); + } + + // Slower path for non-contiguous values + Span buffer = stackalloc byte[256]; + var bytesWritten = value.CopyTo(buffer); + return ExtractLengthPrefixedValue(buffer.Slice(offset, bytesWritten - offset)); + } + + internal static RedisValue ExtractLengthPrefixedValue(ReadOnlySpan span) + { + var colonIndex = span.IndexOf((byte)':'); + if (colonIndex > 0 && Utf8Parser.TryParse(span.Slice(0, colonIndex), out int length, out _)) + { + var startIndex = colonIndex + 1; + if (startIndex + length <= span.Length) + { + return span.Slice(startIndex, length).ToArray(); + } + } + return RedisValue.Null; + } + /// /// Get the number of bytes in the key. /// @@ -355,7 +651,7 @@ public bool TryCopyKey(Span destination, out int charsWritten) /// /// Get the portion of the channel after the "__{keyspace|keyevent}@{db}__:". /// - private ReadOnlySpan ChannelSuffix + internal ReadOnlySpan ChannelSuffix { get { @@ -396,6 +692,69 @@ public bool IsType(ReadOnlySpan type) return ChannelSuffix.SequenceEqual(type); } + if (IsSubKeySpace) + { + // For SubKeySpace, the type is before the | in the payload + if (_value.TryGetSpan(out var direct)) + { + var pipeIndex = direct.IndexOf((byte)'|'); + if (pipeIndex > 0) + { + return direct.Slice(0, pipeIndex).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); + var pipeIndex2 = localCopy.Slice(0, count).IndexOf((byte)'|'); + bool result = pipeIndex2 > 0 && localCopy.Slice(0, pipeIndex2).SequenceEqual(type); + if (lease is not null) ArrayPool.Shared.Return(lease); + return result; + } + + if (IsSubKeySpaceEvent) + { + // For SubKeySpaceEvent, the type is in the channel suffix before the | + var suffix = ChannelSuffix; + var pipeIndex = suffix.IndexOf((byte)'|'); + if (pipeIndex > 0) + { + return suffix.Slice(0, pipeIndex).SequenceEqual(type); + } + return false; + } + + if (IsSubKeyEvent) + { + // For SubKeyEvent, the type is in the channel suffix + return ChannelSuffix.SequenceEqual(type); + } + + if (IsSubKeySpaceItem) + { + // For SubKeySpaceItem, the type is in the payload/value + 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; + } + return false; } @@ -427,6 +786,64 @@ public KeyNotificationType Type // then the channel contains the event-type, and the payload contains the key return KeyNotificationTypeMetadata.Parse(ChannelSuffix); } + else if (IsSubKeySpace) + { + // __subkeyspace@__: with payload |: + // payload contains |... + if (_value.TryGetSpan(out var direct)) + { + var pipeIndex = direct.IndexOf((byte)'|'); + if (pipeIndex > 0) + { + return KeyNotificationTypeMetadata.Parse(direct.Slice(0, pipeIndex)); + } + } + + // Fallback: copy to stack and try again + if (_value.GetByteCount() <= KeyNotificationTypeMetadata.BufferBytes) + { + Span localCopy = stackalloc byte[KeyNotificationTypeMetadata.BufferBytes]; + var len = _value.CopyTo(localCopy); + var pipeIndex = localCopy.Slice(0, len).IndexOf((byte)'|'); + if (pipeIndex > 0) + { + return KeyNotificationTypeMetadata.Parse(localCopy.Slice(0, pipeIndex)); + } + } + } + else if (IsSubKeyEvent) + { + // __subkeyevent@__: with payload :|: + // channel contains the event-type + return KeyNotificationTypeMetadata.Parse(ChannelSuffix); + } + else if (IsSubKeySpaceItem) + { + // __subkeyspaceitem@__:\n with payload + // payload contains the event-type + if (_value.TryGetSpan(out var direct)) + { + return KeyNotificationTypeMetadata.Parse(direct); + } + + if (_value.GetByteCount() <= KeyNotificationTypeMetadata.BufferBytes) + { + Span localCopy = stackalloc byte[KeyNotificationTypeMetadata.BufferBytes]; + var len = _value.CopyTo(localCopy); + return KeyNotificationTypeMetadata.Parse(localCopy.Slice(0, len)); + } + } + else if (IsSubKeySpaceEvent) + { + // __subkeyspaceevent@__:| with payload : + // channel contains event|key - extract the event part before the pipe + var suffix = ChannelSuffix; + var pipeIndex = suffix.IndexOf((byte)'|'); + if (pipeIndex > 0) + { + return KeyNotificationTypeMetadata.Parse(suffix.Slice(0, pipeIndex)); + } + } return KeyNotificationType.Unknown; } } @@ -434,25 +851,47 @@ public KeyNotificationType Type /// /// Indicates whether this notification originated from a keyspace notification, for example __keyspace@4__:mykey with payload set. /// - public bool IsKeySpace + public bool IsKeySpace => _kind == KeyNotificationKind.KeySpace; + + /// + /// Indicates whether this notification originated from a keyevent notification, for example __keyevent@4__:set with payload mykey. + /// + public bool IsKeyEvent => _kind == KeyNotificationKind.KeyEvent; + + /// + /// Indicates whether this notification originated from a subkeyspace notification, for example __subkeyspace@4__:mykey with payload hset|5:field. + /// + public bool IsSubKeySpace { - get - { - var span = _channel.Span; - return span.Length >= KeySpacePrefix.Length + MinSuffixBytes && KeySpacePrefix.IsCS(span.Slice(0, KeySpacePrefix.Length), AsciiHash.HashCS(span)); - } + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + get => _kind == KeyNotificationKind.SubKeySpace; } /// - /// Indicates whether this notification originated from a keyevent notification, for example __keyevent@4__:set with payload mykey. + /// Indicates whether this notification originated from a subkeyevent notification, for example __subkeyevent@4__:hset with payload 5:mykey|5:field. /// - public bool IsKeyEvent + public bool IsSubKeyEvent { - get - { - var span = _channel.Span; - return span.Length >= KeyEventPrefix.Length + MinSuffixBytes && KeyEventPrefix.IsCS(span.Slice(0, KeyEventPrefix.Length), AsciiHash.HashCS(span)); - } + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + get => _kind == KeyNotificationKind.SubKeyEvent; + } + + /// + /// Indicates whether this notification originated from a subkeyspaceitem notification, for example __subkeyspaceitem@4__:mykey\nfield with payload hset. + /// + public bool IsSubKeySpaceItem + { + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + get => _kind == KeyNotificationKind.SubKeySpaceItem; + } + + /// + /// Indicates whether this notification originated from a subkeyspaceevent notification, for example __subkeyspaceevent@4__:hset|mykey with payload 5:field. + /// + public bool IsSubKeySpaceEvent + { + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + get => _kind == KeyNotificationKind.SubKeySpaceEvent; } /// @@ -491,4 +930,24 @@ internal static partial class KeySpacePrefix internal static partial class KeyEventPrefix { } + + [AsciiHash("__subkeyspace@")] + internal static partial class SubKeySpacePrefix + { + } + + [AsciiHash("__subkeyevent@")] + internal static partial class SubKeyEventPrefix + { + } + + [AsciiHash("__subkeyspaceitem@")] + internal static partial class SubKeySpaceItemPrefix + { + } + + [AsciiHash("__subkeyspaceevent@")] + internal static partial class SubKeySpaceEventPrefix + { + } } diff --git a/src/StackExchange.Redis/KeyNotificationType.cs b/src/StackExchange.Redis/KeyNotificationType.cs index d45d11e47..bf0db0991 100644 --- a/src/StackExchange.Redis/KeyNotificationType.cs +++ b/src/StackExchange.Redis/KeyNotificationType.cs @@ -1,4 +1,5 @@ -using RESPite; +using System.Diagnostics.CodeAnalysis; +using RESPite; namespace StackExchange.Redis; @@ -111,6 +112,8 @@ public enum KeyNotificationType ZRemByScore = 48, [AsciiHash("zrem")] ZRem = 49, + [AsciiHash("hexpire")] + HExpire = 50, // side-effect notifications [AsciiHash("expired")] diff --git a/src/StackExchange.Redis/KeyNotificationTypeMetadata.cs b/src/StackExchange.Redis/KeyNotificationTypeMetadata.cs index 594fd29c2..ff11f4092 100644 --- a/src/StackExchange.Redis/KeyNotificationTypeMetadata.cs +++ b/src/StackExchange.Redis/KeyNotificationTypeMetadata.cs @@ -67,6 +67,7 @@ public static KeyNotificationType Parse(ReadOnlySpan value) KeyNotificationType.ZRemByRank => "zrembyrank"u8, KeyNotificationType.ZRemByScore => "zrembyscore"u8, KeyNotificationType.ZRem => "zrem"u8, + KeyNotificationType.HExpire => "hexpire"u8, KeyNotificationType.Expired => "expired"u8, KeyNotificationType.Evicted => "evicted"u8, KeyNotificationType.New => "new"u8, diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index ab058de62..e4f0d6292 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,22 @@ #nullable enable +[SER006]static StackExchange.Redis.RedisChannel.SubKeyEvent(StackExchange.Redis.KeyNotificationType type, int? database = null) -> StackExchange.Redis.RedisChannel +[SER006]static StackExchange.Redis.RedisChannel.SubKeyEvent(System.ReadOnlySpan type, int? database) -> StackExchange.Redis.RedisChannel +[SER006]static StackExchange.Redis.RedisChannel.SubKeySpacePattern(in StackExchange.Redis.RedisKey pattern, int? database = null) -> StackExchange.Redis.RedisChannel +[SER006]static StackExchange.Redis.RedisChannel.SubKeySpacePrefix(in StackExchange.Redis.RedisKey prefix, int? database = null) -> StackExchange.Redis.RedisChannel +[SER006]static StackExchange.Redis.RedisChannel.SubKeySpacePrefix(System.ReadOnlySpan prefix, int? database = null) -> StackExchange.Redis.RedisChannel +[SER006]static StackExchange.Redis.RedisChannel.SubKeySpaceSingleKey(in StackExchange.Redis.RedisKey key, int database) -> StackExchange.Redis.RedisChannel +[SER006]StackExchange.Redis.KeyNotification.GetSubKey() -> StackExchange.Redis.RedisValue +[SER006]StackExchange.Redis.KeyNotification.IsSubKeyEvent.get -> bool +[SER006]StackExchange.Redis.KeyNotification.IsSubKeySpace.get -> bool +[SER006]StackExchange.Redis.KeyNotification.IsSubKeySpaceEvent.get -> bool +[SER006]StackExchange.Redis.KeyNotification.IsSubKeySpaceItem.get -> bool +StackExchange.Redis.KeyNotification.Kind.get -> StackExchange.Redis.KeyNotificationKind +StackExchange.Redis.KeyNotificationKind +StackExchange.Redis.KeyNotificationKind.KeyEvent = 2 -> StackExchange.Redis.KeyNotificationKind +StackExchange.Redis.KeyNotificationKind.KeySpace = 1 -> StackExchange.Redis.KeyNotificationKind +[SER006]StackExchange.Redis.KeyNotificationKind.SubKeyEvent = 4 -> StackExchange.Redis.KeyNotificationKind +[SER006]StackExchange.Redis.KeyNotificationKind.SubKeySpace = 3 -> StackExchange.Redis.KeyNotificationKind +[SER006]StackExchange.Redis.KeyNotificationKind.SubKeySpaceEvent = 6 -> StackExchange.Redis.KeyNotificationKind +[SER006]StackExchange.Redis.KeyNotificationKind.SubKeySpaceItem = 5 -> StackExchange.Redis.KeyNotificationKind +StackExchange.Redis.KeyNotificationKind.Unknown = 0 -> StackExchange.Redis.KeyNotificationKind +StackExchange.Redis.KeyNotificationType.HExpire = 50 -> StackExchange.Redis.KeyNotificationType diff --git a/src/StackExchange.Redis/RedisChannel.cs b/src/StackExchange.Redis/RedisChannel.cs index 2327d0a0c..b0031986e 100644 --- a/src/StackExchange.Redis/RedisChannel.cs +++ b/src/StackExchange.Redis/RedisChannel.cs @@ -1,7 +1,9 @@ using System; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Text; +using RESPite; namespace StackExchange.Redis { @@ -213,13 +215,13 @@ public static RedisChannel Sharded(string value) => /// public static RedisChannel KeySpaceSingleKey(in RedisKey key, int database) // note we can allow patterns, because we aren't using PSUBSCRIBE - => BuildKeySpaceChannel(key, database, RedisChannelOptions.KeyRouted, default, false, true); + => BuildKeySpaceChannel(key, database, RedisChannelOptions.KeyRouted, default, false, true, subkey: false); /// /// Create a key-notification channel for a pattern, optionally in a specified database. /// public static RedisChannel KeySpacePattern(in RedisKey pattern, int? database = null) - => BuildKeySpaceChannel(pattern, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode, default, appendStar: pattern.IsNull, allowKeyPatterns: true); + => BuildKeySpaceChannel(pattern, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode, default, appendStar: pattern.IsNull, allowKeyPatterns: true, subkey: false); #pragma warning disable RS0026 // competing overloads - disambiguated via OverloadResolutionPriority /// @@ -228,7 +230,7 @@ public static RedisChannel KeySpacePattern(in RedisKey pattern, int? 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); + return BuildKeySpaceChannel(prefix, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode, default, true, false, subkey: false); static void Throw() => throw new ArgumentNullException(nameof(prefix)); } @@ -239,7 +241,7 @@ public static RedisChannel KeySpacePrefix(in RedisKey prefix, int? database = nu public static RedisChannel KeySpacePrefix(ReadOnlySpan prefix, int? database = null) { if (prefix.IsEmpty) Throw(); - return BuildKeySpaceChannel(RedisKey.Null, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode, prefix, true, false); + return BuildKeySpaceChannel(RedisKey.Null, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode, prefix, true, false, subkey: false); static void Throw() => throw new ArgumentNullException(nameof(prefix)); } #pragma warning restore RS0026 // competing overloads - disambiguated via OverloadResolutionPriority @@ -268,13 +270,16 @@ private static ReadOnlySpan AppendDatabase(Span target, int? databas #pragma warning disable RS0027 public static RedisChannel KeyEvent(KeyNotificationType type, int? database = null) #pragma warning restore RS0027 - => KeyEvent(KeyNotificationTypeMetadata.GetRawBytes(type), database); + => CreateKeyEvent(KeyNotificationTypeMetadata.GetRawBytes(type), database, subkey: false); /// /// 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) + => CreateKeyEvent(type, database, subkey: false); + + private static RedisChannel CreateKeyEvent(ReadOnlySpan type, int? database, bool subkey) { if (type.IsEmpty) throw new ArgumentNullException(nameof(type)); @@ -294,13 +299,69 @@ public static RedisChannel KeyEvent(ReadOnlySpan type, int? database) return new RedisChannel(arr, options | RedisChannelOptions.IgnoreChannelPrefix); } + /// + /// Create a subkey (hash) notification channel for a single key in a single database. + /// + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public static RedisChannel SubKeySpaceSingleKey(in RedisKey key, int database) + // note we can allow patterns, because we aren't using PSUBSCRIBE + => BuildKeySpaceChannel(key, database, RedisChannelOptions.KeyRouted, default, false, true, subkey: true); + + /// + /// Create a subkey (hash) notification channel for a pattern, optionally in a specified database. + /// + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public static RedisChannel SubKeySpacePattern(in RedisKey pattern, int? database = null) + => BuildKeySpaceChannel(pattern, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode, default, appendStar: pattern.IsNull, allowKeyPatterns: true, subkey: true); + +#pragma warning disable RS0026 // competing overloads - disambiguated via OverloadResolutionPriority + /// + /// Create a subkey (hash) notification channel using a raw prefix, optionally in a specified database. + /// + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public static RedisChannel SubKeySpacePrefix(in RedisKey prefix, int? database = null) + { + if (prefix.IsEmpty) Throw(); + return BuildKeySpaceChannel(prefix, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode, default, true, false, subkey: true); + static void Throw() => throw new ArgumentNullException(nameof(prefix)); + } + + /// + /// Create a key-notification channel using a raw prefix, optionally in a specified database. + /// + [OverloadResolutionPriority(1)] + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public static RedisChannel SubKeySpacePrefix(ReadOnlySpan prefix, int? database = null) + { + if (prefix.IsEmpty) Throw(); + return BuildKeySpaceChannel(RedisKey.Null, database, RedisChannelOptions.Pattern | RedisChannelOptions.MultiNode, prefix, true, false, subkey: true); + static void Throw() => throw new ArgumentNullException(nameof(prefix)); + } + + /// + /// Create a subkey (hash) event-notification channel for a given event type, optionally in a specified database. + /// +#pragma warning disable RS0027 + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public static RedisChannel SubKeyEvent(KeyNotificationType type, int? database = null) +#pragma warning restore RS0027 + => CreateKeyEvent(KeyNotificationTypeMetadata.GetRawBytes(type), database, subkey: true); + + /// + /// Create a subkey (hash) 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 . + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public static RedisChannel SubKeyEvent(ReadOnlySpan type, int? database) + => CreateKeyEvent(type, database, subkey: true); + private static Span AppendAndAdvance(Span target, scoped ReadOnlySpan value) { value.CopyTo(target); return target.Slice(value.Length); } - private static RedisChannel BuildKeySpaceChannel(in RedisKey key, int? database, RedisChannelOptions options, ReadOnlySpan suffix, bool appendStar, bool allowKeyPatterns) + private static RedisChannel BuildKeySpaceChannel(in RedisKey key, int? database, RedisChannelOptions options, ReadOnlySpan suffix, bool appendStar, bool allowKeyPatterns, bool subkey) { int fullKeyLength = key.TotalLength() + suffix.Length + (appendStar ? 1 : 0); if (appendStar & (options & RedisChannelOptions.Pattern) == 0) throw new ArgumentNullException(nameof(key)); @@ -311,7 +372,7 @@ private static RedisChannel BuildKeySpaceChannel(in RedisKey key, int? database, // __keyspace@{db}__:{key}[*] var arr = new byte[14 + db.Length + fullKeyLength]; - var target = AppendAndAdvance(arr.AsSpan(), "__keyspace@"u8); + var target = AppendAndAdvance(arr.AsSpan(), subkey ? "__subkeyspace@"u8 : "__keyspace@"u8); target = AppendAndAdvance(target, db); target = AppendAndAdvance(target, "__:"u8); var keySpan = target; // remember this for if we need to check for patterns diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs index 0a70aa739..96e7892d8 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs @@ -1,5 +1,6 @@ using System; using System.Buffers; +using System.Collections.Generic; using System.Text; using Xunit; using Xunit.Sdk; @@ -392,10 +393,15 @@ public void DefaultKeyNotification_HasExpectedProperties() Assert.False(notification.IsKeySpace); Assert.False(notification.IsKeyEvent); + Assert.False(notification.IsSubKeySpace); + Assert.False(notification.IsSubKeyEvent); + Assert.False(notification.IsSubKeySpaceItem); + Assert.False(notification.IsSubKeySpaceEvent); Assert.Equal(-1, notification.Database); Assert.Equal(KeyNotificationType.Unknown, notification.Type); Assert.False(notification.IsType("del"u8)); Assert.True(notification.GetKey().IsNull); + Assert.True(notification.GetSubKey().IsNull); Assert.Equal(0, notification.GetKeyByteCount()); Assert.Equal(0, notification.GetKeyMaxByteCount()); Assert.Equal(0, notification.GetKeyCharCount()); @@ -459,6 +465,7 @@ public void DefaultKeyNotification_HasExpectedProperties() [InlineData("zrembyrank", KeyNotificationType.ZRemByRank)] [InlineData("zrembyscore", KeyNotificationType.ZRemByScore)] [InlineData("zrem", KeyNotificationType.ZRem)] + [InlineData("hexpire", KeyNotificationType.HExpire)] [InlineData("expired", KeyNotificationType.Expired)] [InlineData("evicted", KeyNotificationType.Evicted)] [InlineData("new", KeyNotificationType.New)] @@ -695,4 +702,339 @@ public void KeyNotificationKeyStripping(bool asString) Assert.Equal(3, charsWritten); Assert.Equal("abc", clob.Slice(0, charsWritten).ToString()); } + + [Fact] + public void SubKeySpace_HSet_ParsesCorrectly() + { + // __subkeyspace@4__:mykey with payload hset|6:field1 + var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); + RedisValue value = "hset|6:field1"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + Assert.False(notification.IsKeySpace); + Assert.False(notification.IsKeyEvent); + Assert.True(notification.IsSubKeySpace); + Assert.False(notification.IsSubKeyEvent); + Assert.False(notification.IsSubKeySpaceItem); + Assert.False(notification.IsSubKeySpaceEvent); + + Assert.Equal(4, notification.Database); + Assert.Equal(KeyNotificationType.HSet, notification.Type); + Assert.True(notification.IsType("hset"u8)); + Assert.Equal("mykey", (string?)notification.GetKey()); + Assert.Equal("field1", (string?)notification.GetSubKey()); + } + + [Fact] + public void SubKeyEvent_HSet_ParsesCorrectly() + { + // __subkeyevent@4__:hset with payload 5:mykey|6:field1 + var channel = RedisChannel.Literal("__subkeyevent@4__:hset"); + RedisValue value = "5:mykey|6:field1"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + Assert.False(notification.IsKeySpace); + Assert.False(notification.IsKeyEvent); + Assert.False(notification.IsSubKeySpace); + Assert.True(notification.IsSubKeyEvent); + Assert.False(notification.IsSubKeySpaceItem); + Assert.False(notification.IsSubKeySpaceEvent); + + Assert.Equal(4, notification.Database); + Assert.Equal(KeyNotificationType.HSet, notification.Type); + Assert.True(notification.IsType("hset"u8)); + Assert.Equal("mykey", (string?)notification.GetKey()); + Assert.Equal("field1", (string?)notification.GetSubKey()); + } + + [Fact] + public void SubKeySpaceItem_HSet_ParsesCorrectly() + { + // __subkeyspaceitem@4__:mykey\nfield1 with payload hset + var channel = RedisChannel.Literal("__subkeyspaceitem@4__:mykey\nfield1"); + RedisValue value = "hset"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + Assert.False(notification.IsKeySpace); + Assert.False(notification.IsKeyEvent); + Assert.False(notification.IsSubKeySpace); + Assert.False(notification.IsSubKeyEvent); + Assert.True(notification.IsSubKeySpaceItem); + Assert.False(notification.IsSubKeySpaceEvent); + + Assert.Equal(4, notification.Database); + Assert.Equal(KeyNotificationType.HSet, notification.Type); + Assert.True(notification.IsType("hset"u8)); + Assert.Equal("mykey", (string?)notification.GetKey()); + Assert.Equal("field1", (string?)notification.GetSubKey()); + } + + [Fact] + public void SubKeySpaceEvent_HSet_ParsesCorrectly() + { + // __subkeyspaceevent@4__:hset|mykey with payload 6:field1 + var channel = RedisChannel.Literal("__subkeyspaceevent@4__:hset|mykey"); + RedisValue value = "6:field1"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + Assert.False(notification.IsKeySpace); + Assert.False(notification.IsKeyEvent); + Assert.False(notification.IsSubKeySpace); + Assert.False(notification.IsSubKeyEvent); + Assert.False(notification.IsSubKeySpaceItem); + Assert.True(notification.IsSubKeySpaceEvent); + + Assert.Equal(4, notification.Database); + Assert.Equal(KeyNotificationType.HSet, notification.Type); + Assert.True(notification.IsType("hset"u8)); + Assert.Equal("mykey", (string?)notification.GetKey()); + Assert.Equal("field1", (string?)notification.GetSubKey()); + } + + [Fact] + public void ExtractLengthPrefixedValue_ParsesCorrectly() + { + // Test the length-prefixed value extraction helper + var result1 = KeyNotification.ExtractLengthPrefixedValue("6:field1"u8); + Assert.Equal("field1", (string?)result1); + + var result2 = KeyNotification.ExtractLengthPrefixedValue("5:mykey"u8); + Assert.Equal("mykey", (string?)result2); + + var result3 = KeyNotification.ExtractLengthPrefixedValue("11:hello world"u8); + Assert.Equal("hello world", (string?)result3); + + // Test invalid formats + var result4 = KeyNotification.ExtractLengthPrefixedValue("invalid"u8); + Assert.True(result4.IsNull); + + var result5 = KeyNotification.ExtractLengthPrefixedValue("10:short"u8); // Length mismatch + Assert.True(result5.IsNull); + } + + [Fact] + public void SubKeySpace_GetSubKey_ReturnsCorrectValue() + { + // Test that GetSubKey returns the expected value for SubKeySpace + var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); + RedisValue value = "hset|6:field1"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + Assert.True(notification.IsSubKeySpace, "IsSubKeySpace should be true"); + + var subKey = notification.GetSubKey(); + Assert.False(subKey.IsNull, $"SubKey should not be null. Value: {value}"); + Assert.Equal("field1", (string?)subKey); + } + + [Fact] + public void ChannelSuffix_SubKeyEvent_ReturnsCorrectValue() + { + // Test that ChannelSuffix returns the expected value for SubKeyEvent + var channel = RedisChannel.Literal("__subkeyevent@4__:hset"); + RedisValue value = "5:mykey|6:field1"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + // Verify the correct Is* property is true + Assert.False(notification.IsKeySpace, "IsKeySpace should be false"); + Assert.False(notification.IsKeyEvent, "IsKeyEvent should be false"); + Assert.False(notification.IsSubKeySpace, "IsSubKeySpace should be false"); + Assert.True(notification.IsSubKeyEvent, "IsSubKeyEvent should be true"); + Assert.False(notification.IsSubKeySpaceItem, "IsSubKeySpaceItem should be false"); + Assert.False(notification.IsSubKeySpaceEvent, "IsSubKeySpaceEvent should be false"); + + var suffix = notification.ChannelSuffix; + var expected = "hset"u8; + + Assert.Equal(expected.Length, suffix.Length); + Assert.True(suffix.SequenceEqual(expected), "ChannelSuffix should equal 'hset'"); + } + + [Fact] + public void SubKeySpace_HExpire_ParsesCorrectly() + { + // __subkeyspace@0__:hash with payload hexpire|5:field + var channel = RedisChannel.Literal("__subkeyspace@0__:hash"); + RedisValue value = "hexpire|5:field"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + Assert.True(notification.IsSubKeySpace); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.HExpire, notification.Type); + Assert.True(notification.IsType("hexpire"u8)); + Assert.Equal("hash", (string?)notification.GetKey()); + Assert.Equal("field", (string?)notification.GetSubKey()); + } + + [Fact] + public void NonSubKeyNotifications_ReturnNullSubKey() + { + // Regular keyspace notification + var channel = RedisChannel.Literal("__keyspace@4__:mykey"); + RedisValue value = "set"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + Assert.True(notification.IsKeySpace); + Assert.True(notification.GetSubKey().IsNull); + + // Regular keyevent notification + channel = RedisChannel.Literal("__keyevent@4__:del"); + value = "mykey"; + + Assert.True(KeyNotification.TryParse(channel, value, out notification)); + Assert.True(notification.IsKeyEvent); + Assert.True(notification.GetSubKey().IsNull); + } + + [Fact] + public void KeyPrefix_KeySpace_MatchingPrefix_ParsesAndStrips() + { + // __keyspace@1__:foo:bar with payload "set" + // Key prefix is "foo:" + var channel = RedisChannel.Literal("__keyspace@1__:foo:bar"); + RedisValue value = "set"; + ReadOnlySpan keyPrefix = "foo:"u8; + + Assert.True(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); + + Assert.True(notification.IsKeySpace); + Assert.Equal(1, notification.Database); + Assert.Equal(KeyNotificationType.Set, notification.Type); + + // The key should NOT include the prefix + Assert.Equal("bar", (string?)notification.GetKey()); + Assert.Equal(3, notification.GetKeyByteCount()); + Assert.Equal(3, notification.GetKeyCharCount()); + } + + [Fact] + public void KeyPrefix_KeySpace_NonMatchingPrefix_ReturnsFalse() + { + // __keyspace@1__:other:bar with payload "set" + // Key prefix is "foo:" + var channel = RedisChannel.Literal("__keyspace@1__:other:bar"); + RedisValue value = "set"; + ReadOnlySpan keyPrefix = "foo:"u8; + + // Should return false because the key doesn't start with "foo:" + Assert.False(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); + } + + [Fact] + public void KeyPrefix_KeyEvent_MatchingPrefix_ParsesAndStrips() + { + // __keyevent@1__:set with payload "foo:bar" + // Key prefix is "foo:" + var channel = RedisChannel.Literal("__keyevent@1__:set"); + RedisValue value = "foo:bar"; + ReadOnlySpan keyPrefix = "foo:"u8; + + Assert.True(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); + + Assert.True(notification.IsKeyEvent); + Assert.Equal(1, notification.Database); + Assert.Equal(KeyNotificationType.Set, notification.Type); + + // The key should NOT include the prefix + Assert.Equal("bar", (string?)notification.GetKey()); + Assert.Equal(3, notification.GetKeyByteCount()); + Assert.Equal(3, notification.GetKeyCharCount()); + } + + [Fact] + public void KeyPrefix_KeyEvent_NonMatchingPrefix_ReturnsFalse() + { + // __keyevent@1__:set with payload "other:bar" + // Key prefix is "foo:" + var channel = RedisChannel.Literal("__keyevent@1__:set"); + RedisValue value = "other:bar"; + ReadOnlySpan keyPrefix = "foo:"u8; + + // Should return false because the key doesn't start with "foo:" + Assert.False(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); + } + + [Fact] + public void KeyPrefix_KeySpace_EmptyPrefix_ParsesWithoutStripping() + { + // __keyspace@1__:mykey with payload "set" + // Empty prefix + var channel = RedisChannel.Literal("__keyspace@1__:mykey"); + RedisValue value = "set"; + ReadOnlySpan keyPrefix = ""u8; + + Assert.True(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); + + Assert.True(notification.IsKeySpace); + + // The key should be unchanged + Assert.Equal("mykey", (string?)notification.GetKey()); + Assert.Equal(5, notification.GetKeyByteCount()); + } + + [Fact] + public void KeyPrefix_KeySpace_PrefixLongerThanKey_ReturnsFalse() + { + // __keyspace@1__:foo with payload "set" + // Key prefix is "foo:bar" which is longer than the actual key + var channel = RedisChannel.Literal("__keyspace@1__:foo"); + RedisValue value = "set"; + ReadOnlySpan keyPrefix = "foo:bar"u8; + + // Should return false because prefix is longer than the key + Assert.False(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); + } + + [Fact] + public void KeyPrefix_KeySpace_ExactMatch_ReturnsEmptyKey() + { + // __keyspace@1__:foo with payload "set" + // Key prefix is exactly "foo" + var channel = RedisChannel.Literal("__keyspace@1__:foo"); + RedisValue value = "set"; + ReadOnlySpan keyPrefix = "foo"u8; + + Assert.True(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); + + Assert.True(notification.IsKeySpace); + + // The key should be empty after stripping the prefix + Assert.Equal("", (string?)notification.GetKey()); + Assert.Equal(0, notification.GetKeyByteCount()); + Assert.Equal(0, notification.GetKeyCharCount()); + } + + [Fact] + public void KeyPrefix_MultiTenantScenario_IsolatesCorrectly() + { + // Simulate a multi-tenant scenario with client prefixes + ReadOnlySpan client1Prefix = "client1234:"u8; + ReadOnlySpan client5678Prefix = "client5678:"u8; + + // Client 1's notification + var channel1 = RedisChannel.Literal("__keyspace@0__:client1234:order/123"); + RedisValue value1 = "set"; + + // Client 2's notification (different client) + var channel2 = RedisChannel.Literal("__keyspace@0__:client5678:order/456"); + RedisValue value2 = "set"; + + // Client 1 should only see their own notifications + Assert.True(KeyNotification.TryParse(client1Prefix, in channel1, in value1, out var notification1)); + Assert.Equal("order/123", (string?)notification1.GetKey()); + + Assert.False(KeyNotification.TryParse(client1Prefix, in channel2, in value2, out _)); + + // Client 2 should only see their own notifications + Assert.True(KeyNotification.TryParse(client5678Prefix, in channel2, in value2, out var notification2)); + Assert.Equal("order/456", (string?)notification2.GetKey()); + + Assert.False(KeyNotification.TryParse(client5678Prefix, in channel1, in value1, out _)); + } } From ce4fde0c3c85bb6926713d768812454e5c860001 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 23 Apr 2026 10:51:47 +0100 Subject: [PATCH 02/14] clean up API --- docs/ReleaseNotes.md | 1 + src/StackExchange.Redis/KeyNotification.cs | 930 +++++++++--------- .../PublicAPI/PublicAPI.Unshipped.txt | 4 - .../KeyNotificationTests.cs | 327 ++++-- 4 files changed, 742 insertions(+), 520 deletions(-) diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md index 32ec98884..23739a627 100644 --- a/docs/ReleaseNotes.md +++ b/docs/ReleaseNotes.md @@ -12,6 +12,7 @@ Current package versions: - Add Redis 8.8 stream negative acknowledgements (`XNACK`) ([#3058 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3058)) - Update experimental `GCRA` APIs and wire protocol terminology from "requests" to "tokens", to match server change ([#3051 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3051)) - Add experimental `Aggregate.Count` support for sorted-set combination operations against Redis 8.8 ([#3059 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3059)) +- Support sub-key (hash field) notifications ([#3062 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3062)) ## 2.12.14 diff --git a/src/StackExchange.Redis/KeyNotification.cs b/src/StackExchange.Redis/KeyNotification.cs index cd680db35..574488b9e 100644 --- a/src/StackExchange.Redis/KeyNotification.cs +++ b/src/StackExchange.Redis/KeyNotification.cs @@ -231,29 +231,15 @@ public int Database // prevalidated format, so we can just skip past the prefix (except for the default value) if (_channel.IsNull) return -1; - // Determine the prefix length based on the channel type + // Determine the prefix length based on the notification kind var fullSpan = _channel.Span; - int prefixLength; - if (fullSpan.Length >= SubKeySpaceEventPrefix.Length && - SubKeySpaceEventPrefix.IsCS(fullSpan.Slice(0, SubKeySpaceEventPrefix.Length), AsciiHash.HashCS(fullSpan))) + int prefixLength = _kind switch { - prefixLength = SubKeySpaceEventPrefix.Length; - } - else if (fullSpan.Length >= SubKeySpaceItemPrefix.Length && - SubKeySpaceItemPrefix.IsCS(fullSpan.Slice(0, SubKeySpaceItemPrefix.Length), AsciiHash.HashCS(fullSpan))) - { - prefixLength = SubKeySpaceItemPrefix.Length; - } - else if (fullSpan.Length >= SubKeySpacePrefix.Length && - (SubKeySpacePrefix.IsCS(fullSpan.Slice(0, SubKeySpacePrefix.Length), AsciiHash.HashCS(fullSpan)) || - SubKeyEventPrefix.IsCS(fullSpan.Slice(0, SubKeyEventPrefix.Length), AsciiHash.HashCS(fullSpan)))) - { - prefixLength = SubKeySpacePrefix.Length; - } - else - { - prefixLength = KeySpacePrefix.Length; // also works for KeyEventPrefix - } + KeyNotificationKind.SubKeySpaceEvent => SubKeySpaceEventPrefix.Length, + KeyNotificationKind.SubKeySpaceItem => SubKeySpaceItemPrefix.Length, + KeyNotificationKind.SubKeySpace or KeyNotificationKind.SubKeyEvent => SubKeySpacePrefix.Length, + _ => KeySpacePrefix.Length, // KeySpace, KeyEvent, and Unknown + }; var span = fullSpan.Slice(prefixLength); var end = span.IndexOf((byte)'_'); // expecting "__:foo" - we'll just stop at the underscore @@ -272,68 +258,56 @@ public int Database /// the , , and APIs can be used. public RedisKey GetKey() { - if (IsKeySpace) + switch (_kind) { - // then the channel contains the key, and the payload contains the event-type - return ChannelSuffix.Slice(_keyOffset).ToArray(); // create an isolated copy - } + case KeyNotificationKind.KeySpace: + case KeyNotificationKind.SubKeySpace: + // Channel contains the key, payload contains the event-type + return ChannelSuffix.Slice(_keyOffset).ToArray(); - if (IsKeyEvent) - { - // then the channel contains the event-type, and the payload contains the key - byte[]? blob = _value; - if (_keyOffset != 0 & blob is not null) - { - return blob.AsSpan(_keyOffset).ToArray(); - } - return blob; - } + case KeyNotificationKind.KeyEvent: + // Channel contains the event-type, payload contains the key + byte[]? blob = _value; + if (_keyOffset != 0 & blob is not null) + { + return blob.AsSpan(_keyOffset).ToArray(); + } + return blob; - if (IsSubKeySpace) - { - // __subkeyspace@__: with payload |: - // channel contains the key - return ChannelSuffix.Slice(_keyOffset).ToArray(); - } + case KeyNotificationKind.SubKeyEvent: + // __subkeyevent@__: with payload :|: + var value = ExtractLengthPrefixedValue(_value, 0); + if (value.IsNull) return RedisKey.Null; + byte[]? bytes = value; + if (_keyOffset != 0 && bytes is not null) + { + return bytes.AsSpan(_keyOffset).ToArray(); + } + return bytes; - if (IsSubKeyEvent) - { - // __subkeyevent@__: with payload :|: - // payload contains :|... - var value = ExtractLengthPrefixedValue(_value, _keyOffset); - if (value.IsNull) return RedisKey.Null; - // Convert RedisValue to RedisKey via byte array - byte[]? bytes = value; - return bytes; - } + case KeyNotificationKind.SubKeySpaceItem: + // __subkeyspaceitem@__:\n with payload + var suffix = ChannelSuffix; + var newlineIndex = suffix.IndexOf((byte)'\n'); + if (newlineIndex > 0) + { + return suffix.Slice(_keyOffset, newlineIndex - _keyOffset).ToArray(); + } + return RedisKey.Null; - if (IsSubKeySpaceItem) - { - // __subkeyspaceitem@__:\n with payload - // channel contains key\nsubkey - extract just the key part - var suffix = ChannelSuffix; - var newlineIndex = suffix.IndexOf((byte)'\n'); - if (newlineIndex > 0) - { - return suffix.Slice(_keyOffset, newlineIndex - _keyOffset).ToArray(); - } - return RedisKey.Null; - } + case KeyNotificationKind.SubKeySpaceEvent: + // __subkeyspaceevent@__:| with payload : + var suffixEvent = ChannelSuffix; + var pipeIndex = suffixEvent.IndexOf((byte)'|'); + if (pipeIndex >= 0 && pipeIndex + 1 < suffixEvent.Length) + { + return suffixEvent.Slice(pipeIndex + 1 + _keyOffset).ToArray(); + } + return RedisKey.Null; - if (IsSubKeySpaceEvent) - { - // __subkeyspaceevent@__:| with payload : - // channel contains event|key - extract the key part after the pipe - var suffix = ChannelSuffix; - var pipeIndex = suffix.IndexOf((byte)'|'); - if (pipeIndex >= 0 && pipeIndex + 1 < suffix.Length) - { - return suffix.Slice(pipeIndex + 1 + _keyOffset).ToArray(); - } - return RedisKey.Null; + default: + return RedisKey.Null; } - - return RedisKey.Null; } /// @@ -343,76 +317,49 @@ public RedisKey GetKey() [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] public RedisValue GetSubKey() { - if (IsSubKeySpace) + switch (_kind) { - // __subkeyspace@__: with payload |:[,...] - // payload contains |: - if (_value.TryGetSpan(out var span)) - { - var pipeIndex = span.IndexOf((byte)'|'); - if (pipeIndex >= 0 && pipeIndex + 1 < span.Length) + case KeyNotificationKind.SubKeySpace: + case KeyNotificationKind.SubKeyEvent: + // Payload contains |: or :|: + if (_value.TryGetSpan(out var span)) { - return ExtractLengthPrefixedValue(span.Slice(pipeIndex + 1)); + var pipeIndex = span.IndexOf((byte)'|'); + if (pipeIndex >= 0 && pipeIndex + 1 < span.Length) + { + return ExtractLengthPrefixedValue(span.Slice(pipeIndex + 1)); + } } - } - else - { - // Fallback for non-contiguous values - Span buffer = stackalloc byte[256]; - var bytesWritten = _value.CopyTo(buffer); - var pipeIndex = buffer.Slice(0, bytesWritten).IndexOf((byte)'|'); - if (pipeIndex >= 0 && pipeIndex + 1 < bytesWritten) + else { - return ExtractLengthPrefixedValue(buffer.Slice(pipeIndex + 1, bytesWritten - pipeIndex - 1)); + // Fallback for non-contiguous values + Span buffer = stackalloc byte[256]; + var bytesWritten = _value.CopyTo(buffer); + var pipeIndex = buffer.Slice(0, bytesWritten).IndexOf((byte)'|'); + if (pipeIndex >= 0 && pipeIndex + 1 < bytesWritten) + { + return ExtractLengthPrefixedValue(buffer.Slice(pipeIndex + 1, bytesWritten - pipeIndex - 1)); + } } - } - } + return RedisValue.Null; - if (IsSubKeyEvent) - { - // __subkeyevent@__: with payload :|:[,...] - // payload contains :|: - if (_value.TryGetSpan(out var span)) - { - var pipeIndex = span.IndexOf((byte)'|'); - if (pipeIndex >= 0 && pipeIndex + 1 < span.Length) - { - return ExtractLengthPrefixedValue(span.Slice(pipeIndex + 1)); - } - } - else - { - // Fallback for non-contiguous values - Span buffer = stackalloc byte[256]; - var bytesWritten = _value.CopyTo(buffer); - var pipeIndex = buffer.Slice(0, bytesWritten).IndexOf((byte)'|'); - if (pipeIndex >= 0 && pipeIndex + 1 < bytesWritten) + case KeyNotificationKind.SubKeySpaceItem: + // __subkeyspaceitem@__:\n with payload + var suffix = ChannelSuffix; + var newlineIndex = suffix.IndexOf((byte)'\n'); + if (newlineIndex >= 0 && newlineIndex + 1 < suffix.Length) { - return ExtractLengthPrefixedValue(buffer.Slice(pipeIndex + 1, bytesWritten - pipeIndex - 1)); + return suffix.Slice(newlineIndex + 1).ToArray(); } - } - } + return RedisValue.Null; - if (IsSubKeySpaceItem) - { - // __subkeyspaceitem@__:\n with payload - // channel contains key\nsubkey - var suffix = ChannelSuffix; - var newlineIndex = suffix.IndexOf((byte)'\n'); - if (newlineIndex >= 0 && newlineIndex + 1 < suffix.Length) - { - return suffix.Slice(newlineIndex + 1).ToArray(); - } - } + case KeyNotificationKind.SubKeySpaceEvent: + // __subkeyspaceevent@__:| with payload :[,...] + return ExtractLengthPrefixedValue(_value, 0); - if (IsSubKeySpaceEvent) - { - // __subkeyspaceevent@__:| with payload :[,...] - // payload contains : - return ExtractLengthPrefixedValue(_value, 0); + default: + return RedisValue.Null; } - - return RedisValue.Null; } // Helper to extract a value prefixed with its length, e.g., "5:hello" -> "hello" @@ -447,56 +394,32 @@ internal static RedisValue ExtractLengthPrefixedValue(ReadOnlySpan span) /// 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() + public int GetKeyByteCount() => _kind switch { - if (IsKeySpace) - { - return ChannelSuffix.Length - _keyOffset; - } - - if (IsKeyEvent) - { - return _value.GetByteCount() - _keyOffset; - } - - return 0; - } + KeyNotificationKind.KeySpace or KeyNotificationKind.SubKeySpace => ChannelSuffix.Length - _keyOffset, + KeyNotificationKind.KeyEvent or KeyNotificationKind.SubKeyEvent => _value.GetByteCount() - _keyOffset, + _ => 0, + }; /// /// Get the maximum number of bytes in the key. /// - public int GetKeyMaxByteCount() + public int GetKeyMaxByteCount() => _kind switch { - if (IsKeySpace) - { - return ChannelSuffix.Length - _keyOffset; - } - - if (IsKeyEvent) - { - return _value.GetMaxByteCount() - _keyOffset; - } - - return 0; - } + KeyNotificationKind.KeySpace or KeyNotificationKind.SubKeySpace => ChannelSuffix.Length - _keyOffset, + KeyNotificationKind.KeyEvent or KeyNotificationKind.SubKeyEvent => _value.GetMaxByteCount() - _keyOffset, + _ => 0, + }; /// /// Get the maximum number of characters in the key, interpreting as UTF8. /// - public int GetKeyMaxCharCount() + public int GetKeyMaxCharCount() => _kind switch { - if (IsKeySpace) - { - return Encoding.UTF8.GetMaxCharCount(ChannelSuffix.Length - _keyOffset); - } - - if (IsKeyEvent) - { - return _value.GetMaxCharCount() - _keyOffset; - } - - return 0; - } + KeyNotificationKind.KeySpace or KeyNotificationKind.SubKeySpace => Encoding.UTF8.GetMaxCharCount(ChannelSuffix.Length - _keyOffset), + KeyNotificationKind.KeyEvent or KeyNotificationKind.SubKeyEvent => _value.GetMaxCharCount() - _keyOffset, + _ => 0, + }; /// /// Get the number of characters in the key, interpreting as UTF8. @@ -504,17 +427,19 @@ public int GetKeyMaxCharCount() /// If a scratch-buffer is required, it may be preferable to use , which is less expensive. public int GetKeyCharCount() { - if (IsKeySpace) + switch (_kind) { - return Encoding.UTF8.GetCharCount(ChannelSuffix.Slice(_keyOffset)); - } + case KeyNotificationKind.KeySpace: + case KeyNotificationKind.SubKeySpace: + return Encoding.UTF8.GetCharCount(ChannelSuffix.Slice(_keyOffset)); - if (IsKeyEvent) - { - return _keyOffset == 0 ? _value.GetCharCount() : SlowMeasure(in this); - } + case KeyNotificationKind.KeyEvent: + case KeyNotificationKind.SubKeyEvent: + return _keyOffset == 0 ? _value.GetCharCount() : SlowMeasure(in this); - return 0; + default: + return 0; + } static int SlowMeasure(in KeyNotification value) { @@ -551,58 +476,139 @@ private static void Return(byte[]? lease) /// public bool TryCopyKey(Span destination, out int bytesWritten) { - if (IsKeySpace) + switch (_kind) { - var suffix = ChannelSuffix.Slice(_keyOffset); - bytesWritten = suffix.Length; // assume success - if (bytesWritten <= destination.Length) - { - suffix.CopyTo(destination); - return true; - } - } - - if (IsKeyEvent) - { - if (_value.TryGetSpan(out var direct)) - { - bytesWritten = direct.Length - _keyOffset; // assume success + case KeyNotificationKind.KeySpace: + case KeyNotificationKind.SubKeySpace: + // Key is in the channel suffix + var suffix = ChannelSuffix.Slice(_keyOffset); + bytesWritten = suffix.Length; // assume success if (bytesWritten <= destination.Length) { - direct.Slice(_keyOffset).CopyTo(destination); + suffix.CopyTo(destination); return true; } - bytesWritten = 0; return false; - } - if (_keyOffset == 0) - { - // get the value to do the hard work - bytesWritten = _value.GetByteCount(); + case KeyNotificationKind.KeyEvent: + // Key is in the value/payload (plain key, not length-prefixed) + if (_value.TryGetSpan(out var direct)) + { + bytesWritten = direct.Length - _keyOffset; + if (bytesWritten <= destination.Length) + { + direct.Slice(_keyOffset).CopyTo(destination); + return true; + } + bytesWritten = 0; + return false; + } + + if (_keyOffset == 0) + { + bytesWritten = _value.GetByteCount(); + if (bytesWritten <= destination.Length) + { + _value.CopyTo(destination); + return true; + } + bytesWritten = 0; + return false; + } + + return SlowCopy(in this, destination, out bytesWritten); + + case KeyNotificationKind.SubKeyEvent: + // Key is length-prefixed in payload: :|: + var keyValue = ExtractLengthPrefixedValue(_value, 0); + if (keyValue.IsNull) + { + bytesWritten = 0; + return false; + } + + if (keyValue.TryGetSpan(out var keySpan)) + { + var slicedSpan = keySpan.Slice(_keyOffset); + bytesWritten = slicedSpan.Length; + if (bytesWritten <= destination.Length) + { + slicedSpan.CopyTo(destination); + return true; + } + return false; + } + + byte[]? keyBytes = keyValue; + if (_keyOffset != 0 && keyBytes is not null) + { + bytesWritten = keyBytes.Length - _keyOffset; + if (bytesWritten <= destination.Length) + { + keyBytes.AsSpan(_keyOffset).CopyTo(destination); + return true; + } + return false; + } + + bytesWritten = keyValue.GetByteCount(); if (bytesWritten <= destination.Length) { - _value.CopyTo(destination); + keyValue.CopyTo(destination); return true; } bytesWritten = 0; return false; - } - return SlowCopy(in this, destination, out bytesWritten); + case KeyNotificationKind.SubKeySpaceItem: + // Key is in channel: __subkeyspaceitem@__:\n + var suffixItem = ChannelSuffix; + var newlineIndex = suffixItem.IndexOf((byte)'\n'); + if (newlineIndex > 0) + { + var keySpanItem = suffixItem.Slice(_keyOffset, newlineIndex - _keyOffset); + bytesWritten = keySpanItem.Length; + if (bytesWritten <= destination.Length) + { + keySpanItem.CopyTo(destination); + return true; + } + return false; + } + bytesWritten = 0; + return false; - 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; - } + case KeyNotificationKind.SubKeySpaceEvent: + // Key is in channel: __subkeyspaceevent@__:| + var suffixEvent = ChannelSuffix; + var pipeIndex = suffixEvent.IndexOf((byte)'|'); + if (pipeIndex >= 0 && pipeIndex + 1 < suffixEvent.Length) + { + var keySpanEvent = suffixEvent.Slice(pipeIndex + 1 + _keyOffset); + bytesWritten = keySpanEvent.Length; + if (bytesWritten <= destination.Length) + { + keySpanEvent.CopyTo(destination); + return true; + } + return false; + } + bytesWritten = 0; + return false; + + default: + bytesWritten = 0; + return false; } - bytesWritten = 0; - return false; + 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; + } } /// @@ -610,42 +616,116 @@ static bool SlowCopy(in KeyNotification value, Span destination, out int b /// public bool TryCopyKey(Span destination, out int charsWritten) { - if (IsKeySpace) - { - var suffix = ChannelSuffix.Slice(_keyOffset); - 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 (_keyOffset == 0) // can use short-cut - { - if (_value.GetMaxCharCount() <= destination.Length || _value.GetCharCount() <= destination.Length) + switch (_kind) + { + case KeyNotificationKind.KeySpace: + case KeyNotificationKind.SubKeySpace: + // Key is in the channel suffix + var suffix = ChannelSuffix.Slice(_keyOffset); + if (Encoding.UTF8.GetMaxCharCount(suffix.Length) <= destination.Length || + Encoding.UTF8.GetCharCount(suffix) <= destination.Length) { - charsWritten = _value.CopyTo(destination); + charsWritten = Encoding.UTF8.GetChars(suffix, 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; + return false; - charsWritten = 0; - return false; + case KeyNotificationKind.KeyEvent: + // Key is in the value/payload (plain key, not length-prefixed) + if (_keyOffset == 0) // can use short-cut + { + 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; + + case KeyNotificationKind.SubKeyEvent: + // Key is length-prefixed in payload: :|: + var keyValue = ExtractLengthPrefixedValue(_value, 0); + if (keyValue.IsNull) + { + charsWritten = 0; + return false; + } + + if (_keyOffset == 0) + { + if (keyValue.GetMaxCharCount() <= destination.Length || keyValue.GetCharCount() <= destination.Length) + { + charsWritten = keyValue.CopyTo(destination); + return true; + } + } + else + { + // Need to slice the extracted key value + byte[]? keyBytes = keyValue; + if (keyBytes is not null) + { + var keySpan = keyBytes.AsSpan(_keyOffset); + if (Encoding.UTF8.GetMaxCharCount(keySpan.Length) <= destination.Length || + Encoding.UTF8.GetCharCount(keySpan) <= destination.Length) + { + charsWritten = Encoding.UTF8.GetChars(keySpan, destination); + return true; + } + } + } + charsWritten = 0; + return false; + + case KeyNotificationKind.SubKeySpaceItem: + // Key is in channel: __subkeyspaceitem@__:\n + var suffixItem = ChannelSuffix; + var newlineIndex = suffixItem.IndexOf((byte)'\n'); + if (newlineIndex > 0) + { + var keyBytes = suffixItem.Slice(_keyOffset, newlineIndex - _keyOffset); + if (Encoding.UTF8.GetMaxCharCount(keyBytes.Length) <= destination.Length || + Encoding.UTF8.GetCharCount(keyBytes) <= destination.Length) + { + charsWritten = Encoding.UTF8.GetChars(keyBytes, destination); + return true; + } + } + charsWritten = 0; + return false; + + case KeyNotificationKind.SubKeySpaceEvent: + // Key is in channel: __subkeyspaceevent@__:| + var suffixEvent = ChannelSuffix; + var pipeIndex = suffixEvent.IndexOf((byte)'|'); + if (pipeIndex >= 0 && pipeIndex + 1 < suffixEvent.Length) + { + var keyBytes = suffixEvent.Slice(pipeIndex + 1 + _keyOffset); + if (Encoding.UTF8.GetMaxCharCount(keyBytes.Length) <= destination.Length || + Encoding.UTF8.GetCharCount(keyBytes) <= destination.Length) + { + charsWritten = Encoding.UTF8.GetChars(keyBytes, destination); + return true; + } + } + charsWritten = 0; + return false; + + default: + charsWritten = 0; + return false; + } } /// @@ -668,94 +748,67 @@ internal ReadOnlySpan ChannelSuffix /// a single successful call to . public bool IsType(ReadOnlySpan type) { - if (IsKeySpace) + switch (_kind) { - 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); - } - - if (IsSubKeySpace) - { - // For SubKeySpace, the type is before the | in the payload - if (_value.TryGetSpan(out var direct)) - { - var pipeIndex = direct.IndexOf((byte)'|'); - if (pipeIndex > 0) + case KeyNotificationKind.KeySpace: + case KeyNotificationKind.SubKeySpaceItem: + // Type is in the payload/value + if (_value.TryGetSpan(out var direct)) { - return direct.Slice(0, pipeIndex).SequenceEqual(type); + 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); - var pipeIndex2 = localCopy.Slice(0, count).IndexOf((byte)'|'); - bool result = pipeIndex2 > 0 && localCopy.Slice(0, pipeIndex2).SequenceEqual(type); - if (lease is not null) ArrayPool.Shared.Return(lease); - return result; - } + 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 (IsSubKeySpaceEvent) - { - // For SubKeySpaceEvent, the type is in the channel suffix before the | - var suffix = ChannelSuffix; - var pipeIndex = suffix.IndexOf((byte)'|'); - if (pipeIndex > 0) - { - return suffix.Slice(0, pipeIndex).SequenceEqual(type); - } - return false; - } + case KeyNotificationKind.KeyEvent: + case KeyNotificationKind.SubKeyEvent: + // Type is in the channel suffix + return ChannelSuffix.SequenceEqual(type); - if (IsSubKeyEvent) - { - // For SubKeyEvent, the type is in the channel suffix - return ChannelSuffix.SequenceEqual(type); - } + case KeyNotificationKind.SubKeySpace: + // Type is before the | in the payload + if (_value.TryGetSpan(out var directSub)) + { + var pipeIndex = directSub.IndexOf((byte)'|'); + if (pipeIndex > 0) + { + return directSub.Slice(0, pipeIndex).SequenceEqual(type); + } + } - if (IsSubKeySpaceItem) - { - // For SubKeySpaceItem, the type is in the payload/value - if (_value.TryGetSpan(out var direct)) - { - return direct.SequenceEqual(type); - } + byte[]? leaseSub = null; + var maxCountSub = _value.GetMaxByteCount(); + Span localCopySub = maxCountSub <= MAX_STACK + ? stackalloc byte[MAX_STACK] + : (leaseSub = ArrayPool.Shared.Rent(maxCountSub)); + var countSub = _value.CopyTo(localCopySub); + var pipeIndexSub = localCopySub.Slice(0, countSub).IndexOf((byte)'|'); + bool resultSub = pipeIndexSub > 0 && localCopySub.Slice(0, pipeIndexSub).SequenceEqual(type); + if (leaseSub is not null) ArrayPool.Shared.Return(leaseSub); + return resultSub; + + case KeyNotificationKind.SubKeySpaceEvent: + // Type is in the channel suffix before the | + var suffix = ChannelSuffix; + var pipeIndexEvent = suffix.IndexOf((byte)'|'); + if (pipeIndexEvent > 0) + { + return suffix.Slice(0, pipeIndexEvent).SequenceEqual(type); + } + return false; - 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; + default: + return false; } - - return false; } /// @@ -766,156 +819,141 @@ public KeyNotificationType Type { get { - if (IsKeySpace) + switch (_kind) { - // then the channel contains the key, and the payload contains the event-type - if (_value.TryGetSpan(out var direct)) - { - return KeyNotificationTypeMetadata.Parse(direct); - } + case KeyNotificationKind.KeySpace: + case KeyNotificationKind.SubKeySpaceItem: + // Payload contains the event-type + if (_value.TryGetSpan(out var direct)) + { + return KeyNotificationTypeMetadata.Parse(direct); + } - if (_value.GetByteCount() <= KeyNotificationTypeMetadata.BufferBytes) - { - Span localCopy = stackalloc byte[KeyNotificationTypeMetadata.BufferBytes]; - var len = _value.CopyTo(localCopy); - return KeyNotificationTypeMetadata.Parse(localCopy.Slice(0, len)); - } - } - else if (IsKeyEvent) - { - // then the channel contains the event-type, and the payload contains the key - return KeyNotificationTypeMetadata.Parse(ChannelSuffix); - } - else if (IsSubKeySpace) - { - // __subkeyspace@__: with payload |: - // payload contains |... - if (_value.TryGetSpan(out var direct)) - { - var pipeIndex = direct.IndexOf((byte)'|'); - if (pipeIndex > 0) + if (_value.GetByteCount() <= KeyNotificationTypeMetadata.BufferBytes) { - return KeyNotificationTypeMetadata.Parse(direct.Slice(0, pipeIndex)); + Span localCopy = stackalloc byte[KeyNotificationTypeMetadata.BufferBytes]; + var len = _value.CopyTo(localCopy); + return KeyNotificationTypeMetadata.Parse(localCopy.Slice(0, len)); } - } + return KeyNotificationType.Unknown; - // Fallback: copy to stack and try again - if (_value.GetByteCount() <= KeyNotificationTypeMetadata.BufferBytes) - { - Span localCopy = stackalloc byte[KeyNotificationTypeMetadata.BufferBytes]; - var len = _value.CopyTo(localCopy); - var pipeIndex = localCopy.Slice(0, len).IndexOf((byte)'|'); - if (pipeIndex > 0) + case KeyNotificationKind.KeyEvent: + case KeyNotificationKind.SubKeyEvent: + // Channel contains the event-type + return KeyNotificationTypeMetadata.Parse(ChannelSuffix); + + case KeyNotificationKind.SubKeySpace: + // Payload contains |: + if (_value.TryGetSpan(out var directSub)) { - return KeyNotificationTypeMetadata.Parse(localCopy.Slice(0, pipeIndex)); + var pipeIndex = directSub.IndexOf((byte)'|'); + if (pipeIndex > 0) + { + return KeyNotificationTypeMetadata.Parse(directSub.Slice(0, pipeIndex)); + } } - } - } - else if (IsSubKeyEvent) - { - // __subkeyevent@__: with payload :|: - // channel contains the event-type - return KeyNotificationTypeMetadata.Parse(ChannelSuffix); - } - else if (IsSubKeySpaceItem) - { - // __subkeyspaceitem@__:\n with payload - // payload contains the event-type - if (_value.TryGetSpan(out var direct)) - { - return KeyNotificationTypeMetadata.Parse(direct); - } - if (_value.GetByteCount() <= KeyNotificationTypeMetadata.BufferBytes) - { - Span localCopy = stackalloc byte[KeyNotificationTypeMetadata.BufferBytes]; - var len = _value.CopyTo(localCopy); - return KeyNotificationTypeMetadata.Parse(localCopy.Slice(0, len)); - } - } - else if (IsSubKeySpaceEvent) - { - // __subkeyspaceevent@__:| with payload : - // channel contains event|key - extract the event part before the pipe - var suffix = ChannelSuffix; - var pipeIndex = suffix.IndexOf((byte)'|'); - if (pipeIndex > 0) - { - return KeyNotificationTypeMetadata.Parse(suffix.Slice(0, pipeIndex)); - } + if (_value.GetByteCount() <= KeyNotificationTypeMetadata.BufferBytes) + { + Span localCopy = stackalloc byte[KeyNotificationTypeMetadata.BufferBytes]; + var len = _value.CopyTo(localCopy); + var pipeIndex = localCopy.Slice(0, len).IndexOf((byte)'|'); + if (pipeIndex > 0) + { + return KeyNotificationTypeMetadata.Parse(localCopy.Slice(0, pipeIndex)); + } + } + return KeyNotificationType.Unknown; + + case KeyNotificationKind.SubKeySpaceEvent: + // Channel contains event|key - extract the event part before the pipe + var suffix = ChannelSuffix; + var pipeIndexEvent = suffix.IndexOf((byte)'|'); + if (pipeIndexEvent > 0) + { + return KeyNotificationTypeMetadata.Parse(suffix.Slice(0, pipeIndexEvent)); + } + return KeyNotificationType.Unknown; + + default: + return KeyNotificationType.Unknown; } - return KeyNotificationType.Unknown; } } /// /// Indicates whether this notification originated from a keyspace notification, for example __keyspace@4__:mykey with payload set. /// + [Obsolete($"Prefer {nameof(KeyNotification)}.{nameof(Kind)}", error: false)] public bool IsKeySpace => _kind == KeyNotificationKind.KeySpace; /// /// Indicates whether this notification originated from a keyevent notification, for example __keyevent@4__:set with payload mykey. /// + [Obsolete($"Prefer {nameof(KeyNotification)}.{nameof(Kind)}", error: false)] public bool IsKeyEvent => _kind == KeyNotificationKind.KeyEvent; - /// - /// Indicates whether this notification originated from a subkeyspace notification, for example __subkeyspace@4__:mykey with payload hset|5:field. - /// - public bool IsSubKeySpace - { - [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] - get => _kind == KeyNotificationKind.SubKeySpace; - } - - /// - /// Indicates whether this notification originated from a subkeyevent notification, for example __subkeyevent@4__:hset with payload 5:mykey|5:field. - /// - public bool IsSubKeyEvent - { - [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] - get => _kind == KeyNotificationKind.SubKeyEvent; - } - - /// - /// Indicates whether this notification originated from a subkeyspaceitem notification, for example __subkeyspaceitem@4__:mykey\nfield with payload hset. - /// - public bool IsSubKeySpaceItem - { - [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] - get => _kind == KeyNotificationKind.SubKeySpaceItem; - } - - /// - /// Indicates whether this notification originated from a subkeyspaceevent notification, for example __subkeyspaceevent@4__:hset|mykey with payload 5:field. - /// - public bool IsSubKeySpaceEvent - { - [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] - get => _kind == KeyNotificationKind.SubKeySpaceEvent; - } - /// /// 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) + switch (_kind) { - return ChannelSuffix.Slice(_keyOffset).StartsWith(prefix); - } + case KeyNotificationKind.KeySpace: + case KeyNotificationKind.SubKeySpace: + // Key is in the channel suffix + return ChannelSuffix.Slice(_keyOffset).StartsWith(prefix); - if (IsKeyEvent) - { - if (_keyOffset == 0) return _value.StartsWith(prefix); + case KeyNotificationKind.KeyEvent: + // Key is in the value/payload (plain key, not length-prefixed) + 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; - } + var span = GetKeySpan(out var lease, stackalloc byte[128]); + bool result = span.StartsWith(prefix); + Return(lease); + return result; - return false; + case KeyNotificationKind.SubKeyEvent: + // Key is length-prefixed in payload: :|: + var keyValue = ExtractLengthPrefixedValue(_value, 0); + if (keyValue.IsNull) return false; + if (_keyOffset == 0) return keyValue.StartsWith(prefix); + + // Need to check the sliced portion + byte[]? keyBytes = keyValue; + if (keyBytes is not null && _keyOffset < keyBytes.Length) + { + return keyBytes.AsSpan(_keyOffset).StartsWith(prefix); + } + return false; + + case KeyNotificationKind.SubKeySpaceItem: + // Key is in channel: __subkeyspaceitem@__:\n + var suffixItem = ChannelSuffix; + var newlineIndex = suffixItem.IndexOf((byte)'\n'); + if (newlineIndex > 0) + { + var keySpan = suffixItem.Slice(_keyOffset, newlineIndex - _keyOffset); + return keySpan.StartsWith(prefix); + } + return false; + + case KeyNotificationKind.SubKeySpaceEvent: + // Key is in channel: __subkeyspaceevent@__:| + var suffixEvent = ChannelSuffix; + var pipeIndex = suffixEvent.IndexOf((byte)'|'); + if (pipeIndex >= 0 && pipeIndex + 1 < suffixEvent.Length) + { + var keySpan = suffixEvent.Slice(pipeIndex + 1 + _keyOffset); + return keySpan.StartsWith(prefix); + } + return false; + + default: + return false; + } } } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index e4f0d6292..77f991f09 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -6,10 +6,6 @@ [SER006]static StackExchange.Redis.RedisChannel.SubKeySpacePrefix(System.ReadOnlySpan prefix, int? database = null) -> StackExchange.Redis.RedisChannel [SER006]static StackExchange.Redis.RedisChannel.SubKeySpaceSingleKey(in StackExchange.Redis.RedisKey key, int database) -> StackExchange.Redis.RedisChannel [SER006]StackExchange.Redis.KeyNotification.GetSubKey() -> StackExchange.Redis.RedisValue -[SER006]StackExchange.Redis.KeyNotification.IsSubKeyEvent.get -> bool -[SER006]StackExchange.Redis.KeyNotification.IsSubKeySpace.get -> bool -[SER006]StackExchange.Redis.KeyNotification.IsSubKeySpaceEvent.get -> bool -[SER006]StackExchange.Redis.KeyNotification.IsSubKeySpaceItem.get -> bool StackExchange.Redis.KeyNotification.Kind.get -> StackExchange.Redis.KeyNotificationKind StackExchange.Redis.KeyNotificationKind StackExchange.Redis.KeyNotificationKind.KeyEvent = 2 -> StackExchange.Redis.KeyNotificationKind diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs index 96e7892d8..60cbec3e3 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs @@ -37,8 +37,7 @@ public void Keyspace_Del_ParsesCorrectly() Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - Assert.True(notification.IsKeySpace); - Assert.False(notification.IsKeyEvent); + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); Assert.Equal(1, notification.Database); Assert.Equal(KeyNotificationType.Del, notification.Type); Assert.True(notification.IsType("del"u8)); @@ -47,6 +46,18 @@ public void Keyspace_Del_ParsesCorrectly() Assert.Equal(5, notification.GetKeyMaxByteCount()); Assert.Equal(5, notification.GetKeyCharCount()); Assert.Equal(6, notification.GetKeyMaxCharCount()); + + // Test TryCopyKey (bytes) + Span keyBuffer = stackalloc byte[10]; + Assert.True(notification.TryCopyKey(keyBuffer, out var bytesWritten)); + Assert.Equal(5, bytesWritten); + Assert.Equal("mykey", Encoding.UTF8.GetString(keyBuffer.Slice(0, bytesWritten))); + + // Test TryCopyKey (chars) + Span charBuffer = stackalloc char[10]; + Assert.True(notification.TryCopyKey(charBuffer, out var charsWritten)); + Assert.Equal(5, charsWritten); + Assert.Equal("mykey", new string(charBuffer.Slice(0, charsWritten).ToArray())); } [Fact] @@ -58,8 +69,7 @@ public void Keyevent_Del_ParsesCorrectly() Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - Assert.False(notification.IsKeySpace); - Assert.True(notification.IsKeyEvent); + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); Assert.Equal(42, notification.Database); Assert.Equal(KeyNotificationType.Del, notification.Type); Assert.True(notification.IsType("del"u8)); @@ -78,7 +88,7 @@ public void Keyspace_Set_ParsesCorrectly() Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - Assert.True(notification.IsKeySpace); + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); Assert.Equal(0, notification.Database); Assert.Equal(KeyNotificationType.Set, notification.Type); Assert.True(notification.IsType("set"u8)); @@ -97,7 +107,7 @@ public void Keyevent_Expire_ParsesCorrectly() Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - Assert.True(notification.IsKeyEvent); + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); Assert.Equal(5, notification.Database); Assert.Equal(KeyNotificationType.Expire, notification.Type); Assert.True(notification.IsType("expire"u8)); @@ -116,7 +126,7 @@ public void Keyspace_Expired_ParsesCorrectly() Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - Assert.True(notification.IsKeySpace); + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); Assert.Equal(3, notification.Database); Assert.Equal(KeyNotificationType.Expired, notification.Type); Assert.True(notification.IsType("expired"u8)); @@ -135,7 +145,7 @@ public void Keyevent_LPush_ParsesCorrectly() Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - Assert.True(notification.IsKeyEvent); + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); Assert.Equal(0, notification.Database); Assert.Equal(KeyNotificationType.LPush, notification.Type); Assert.True(notification.IsType("lpush"u8)); @@ -154,7 +164,7 @@ public void Keyspace_HSet_ParsesCorrectly() Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - Assert.True(notification.IsKeySpace); + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); Assert.Equal(2, notification.Database); Assert.Equal(KeyNotificationType.HSet, notification.Type); Assert.True(notification.IsType("hset"u8)); @@ -173,7 +183,7 @@ public void Keyevent_ZAdd_ParsesCorrectly() Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - Assert.True(notification.IsKeyEvent); + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); Assert.Equal(7, notification.Database); Assert.Equal(KeyNotificationType.ZAdd, notification.Type); Assert.True(notification.IsType("zadd"u8)); @@ -192,7 +202,7 @@ public void CustomEventWithUnusualValue_Works() Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - Assert.True(notification.IsKeyEvent); + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); Assert.Equal(7, notification.Database); Assert.Equal(KeyNotificationType.Unknown, notification.Type); Assert.False(notification.IsType("zadd"u8)); @@ -230,7 +240,7 @@ public void TryCopyKey_FailsWithSmallBuffer() Span buffer = stackalloc byte[3]; // too small Assert.False(notification.TryCopyKey(buffer, out var bytesWritten)); - Assert.Equal(0, bytesWritten); + Assert.Equal(7, bytesWritten); // Should report the actual size needed (length of "testkey") } [Fact] @@ -259,7 +269,7 @@ public void Keyspace_UnknownEventType_ReturnsUnknown() Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - Assert.True(notification.IsKeySpace); + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); Assert.Equal(0, notification.Database); Assert.Equal(KeyNotificationType.Unknown, notification.Type); Assert.False(notification.IsType("del"u8)); @@ -274,7 +284,7 @@ public void Keyevent_UnknownEventType_ReturnsUnknown() Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - Assert.True(notification.IsKeyEvent); + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); Assert.Equal(0, notification.Database); Assert.Equal(KeyNotificationType.Unknown, notification.Type); Assert.False(notification.IsType("del"u8)); @@ -289,7 +299,7 @@ public void Keyspace_WithColonInKey_ParsesCorrectly() Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - Assert.True(notification.IsKeySpace); + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); Assert.Equal(0, notification.Database); Assert.Equal(KeyNotificationType.Del, notification.Type); Assert.True(notification.IsType("del"u8)); @@ -304,7 +314,7 @@ public void Keyevent_Evicted_ParsesCorrectly() Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - Assert.True(notification.IsKeyEvent); + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); Assert.Equal(1, notification.Database); Assert.Equal(KeyNotificationType.Evicted, notification.Type); Assert.True(notification.IsType("evicted"u8)); @@ -319,7 +329,7 @@ public void Keyspace_New_ParsesCorrectly() Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - Assert.True(notification.IsKeySpace); + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); Assert.Equal(0, notification.Database); Assert.Equal(KeyNotificationType.New, notification.Type); Assert.True(notification.IsType("new"u8)); @@ -334,7 +344,7 @@ public void Keyevent_XGroupCreate_ParsesCorrectly() Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - Assert.True(notification.IsKeyEvent); + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); Assert.Equal(0, notification.Database); Assert.Equal(KeyNotificationType.XGroupCreate, notification.Type); Assert.True(notification.IsType("xgroup-create"u8)); @@ -349,7 +359,7 @@ public void Keyspace_TypeChanged_ParsesCorrectly() Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - Assert.True(notification.IsKeySpace); + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); Assert.Equal(0, notification.Database); Assert.Equal(KeyNotificationType.TypeChanged, notification.Type); Assert.True(notification.IsType("type_changed"u8)); @@ -364,7 +374,7 @@ public void Keyevent_HighDatabaseNumber_ParsesCorrectly() Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - Assert.True(notification.IsKeyEvent); + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); Assert.Equal(999, notification.Database); Assert.Equal(KeyNotificationType.Set, notification.Type); Assert.True(notification.IsType("set"u8)); @@ -379,7 +389,7 @@ public void Keyevent_NonIntegerDatabase_ParsesWellEnough() Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); - Assert.True(notification.IsKeyEvent); + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); Assert.Equal(-1, notification.Database); Assert.Equal(KeyNotificationType.Set, notification.Type); Assert.True(notification.IsType("set"u8)); @@ -391,12 +401,7 @@ public void DefaultKeyNotification_HasExpectedProperties() { var notification = default(KeyNotification); - Assert.False(notification.IsKeySpace); - Assert.False(notification.IsKeyEvent); - Assert.False(notification.IsSubKeySpace); - Assert.False(notification.IsSubKeyEvent); - Assert.False(notification.IsSubKeySpaceItem); - Assert.False(notification.IsSubKeySpaceEvent); + Assert.Equal(KeyNotificationKind.Unknown, notification.Kind); Assert.Equal(-1, notification.Database); Assert.Equal(KeyNotificationType.Unknown, notification.Type); Assert.False(notification.IsType("del"u8)); @@ -712,13 +717,7 @@ public void SubKeySpace_HSet_ParsesCorrectly() Assert.True(KeyNotification.TryParse(channel, value, out var notification)); - Assert.False(notification.IsKeySpace); - Assert.False(notification.IsKeyEvent); - Assert.True(notification.IsSubKeySpace); - Assert.False(notification.IsSubKeyEvent); - Assert.False(notification.IsSubKeySpaceItem); - Assert.False(notification.IsSubKeySpaceEvent); - + Assert.Equal(KeyNotificationKind.SubKeySpace, notification.Kind); Assert.Equal(4, notification.Database); Assert.Equal(KeyNotificationType.HSet, notification.Type); Assert.True(notification.IsType("hset"u8)); @@ -735,13 +734,7 @@ public void SubKeyEvent_HSet_ParsesCorrectly() Assert.True(KeyNotification.TryParse(channel, value, out var notification)); - Assert.False(notification.IsKeySpace); - Assert.False(notification.IsKeyEvent); - Assert.False(notification.IsSubKeySpace); - Assert.True(notification.IsSubKeyEvent); - Assert.False(notification.IsSubKeySpaceItem); - Assert.False(notification.IsSubKeySpaceEvent); - + Assert.Equal(KeyNotificationKind.SubKeyEvent, notification.Kind); Assert.Equal(4, notification.Database); Assert.Equal(KeyNotificationType.HSet, notification.Type); Assert.True(notification.IsType("hset"u8)); @@ -758,13 +751,7 @@ public void SubKeySpaceItem_HSet_ParsesCorrectly() Assert.True(KeyNotification.TryParse(channel, value, out var notification)); - Assert.False(notification.IsKeySpace); - Assert.False(notification.IsKeyEvent); - Assert.False(notification.IsSubKeySpace); - Assert.False(notification.IsSubKeyEvent); - Assert.True(notification.IsSubKeySpaceItem); - Assert.False(notification.IsSubKeySpaceEvent); - + Assert.Equal(KeyNotificationKind.SubKeySpaceItem, notification.Kind); Assert.Equal(4, notification.Database); Assert.Equal(KeyNotificationType.HSet, notification.Type); Assert.True(notification.IsType("hset"u8)); @@ -781,13 +768,7 @@ public void SubKeySpaceEvent_HSet_ParsesCorrectly() Assert.True(KeyNotification.TryParse(channel, value, out var notification)); - Assert.False(notification.IsKeySpace); - Assert.False(notification.IsKeyEvent); - Assert.False(notification.IsSubKeySpace); - Assert.False(notification.IsSubKeyEvent); - Assert.False(notification.IsSubKeySpaceItem); - Assert.True(notification.IsSubKeySpaceEvent); - + Assert.Equal(KeyNotificationKind.SubKeySpaceEvent, notification.Kind); Assert.Equal(4, notification.Database); Assert.Equal(KeyNotificationType.HSet, notification.Type); Assert.True(notification.IsType("hset"u8)); @@ -824,7 +805,7 @@ public void SubKeySpace_GetSubKey_ReturnsCorrectValue() RedisValue value = "hset|6:field1"; Assert.True(KeyNotification.TryParse(channel, value, out var notification)); - Assert.True(notification.IsSubKeySpace, "IsSubKeySpace should be true"); + Assert.Equal(KeyNotificationKind.SubKeySpace, notification.Kind); var subKey = notification.GetSubKey(); Assert.False(subKey.IsNull, $"SubKey should not be null. Value: {value}"); @@ -840,13 +821,7 @@ public void ChannelSuffix_SubKeyEvent_ReturnsCorrectValue() Assert.True(KeyNotification.TryParse(channel, value, out var notification)); - // Verify the correct Is* property is true - Assert.False(notification.IsKeySpace, "IsKeySpace should be false"); - Assert.False(notification.IsKeyEvent, "IsKeyEvent should be false"); - Assert.False(notification.IsSubKeySpace, "IsSubKeySpace should be false"); - Assert.True(notification.IsSubKeyEvent, "IsSubKeyEvent should be true"); - Assert.False(notification.IsSubKeySpaceItem, "IsSubKeySpaceItem should be false"); - Assert.False(notification.IsSubKeySpaceEvent, "IsSubKeySpaceEvent should be false"); + Assert.Equal(KeyNotificationKind.SubKeyEvent, notification.Kind); var suffix = notification.ChannelSuffix; var expected = "hset"u8; @@ -864,7 +839,7 @@ public void SubKeySpace_HExpire_ParsesCorrectly() Assert.True(KeyNotification.TryParse(channel, value, out var notification)); - Assert.True(notification.IsSubKeySpace); + Assert.Equal(KeyNotificationKind.SubKeySpace, notification.Kind); Assert.Equal(0, notification.Database); Assert.Equal(KeyNotificationType.HExpire, notification.Type); Assert.True(notification.IsType("hexpire"u8)); @@ -880,7 +855,7 @@ public void NonSubKeyNotifications_ReturnNullSubKey() RedisValue value = "set"; Assert.True(KeyNotification.TryParse(channel, value, out var notification)); - Assert.True(notification.IsKeySpace); + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); Assert.True(notification.GetSubKey().IsNull); // Regular keyevent notification @@ -888,7 +863,7 @@ public void NonSubKeyNotifications_ReturnNullSubKey() value = "mykey"; Assert.True(KeyNotification.TryParse(channel, value, out notification)); - Assert.True(notification.IsKeyEvent); + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); Assert.True(notification.GetSubKey().IsNull); } @@ -903,7 +878,7 @@ public void KeyPrefix_KeySpace_MatchingPrefix_ParsesAndStrips() Assert.True(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); - Assert.True(notification.IsKeySpace); + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); Assert.Equal(1, notification.Database); Assert.Equal(KeyNotificationType.Set, notification.Type); @@ -937,7 +912,7 @@ public void KeyPrefix_KeyEvent_MatchingPrefix_ParsesAndStrips() Assert.True(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); - Assert.True(notification.IsKeyEvent); + Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); Assert.Equal(1, notification.Database); Assert.Equal(KeyNotificationType.Set, notification.Type); @@ -971,7 +946,7 @@ public void KeyPrefix_KeySpace_EmptyPrefix_ParsesWithoutStripping() Assert.True(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); - Assert.True(notification.IsKeySpace); + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); // The key should be unchanged Assert.Equal("mykey", (string?)notification.GetKey()); @@ -1002,7 +977,7 @@ public void KeyPrefix_KeySpace_ExactMatch_ReturnsEmptyKey() Assert.True(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); - Assert.True(notification.IsKeySpace); + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); // The key should be empty after stripping the prefix Assert.Equal("", (string?)notification.GetKey()); @@ -1037,4 +1012,216 @@ public void KeyPrefix_MultiTenantScenario_IsolatesCorrectly() Assert.False(KeyNotification.TryParse(client5678Prefix, in channel1, in value1, out _)); } + + [Fact] + public void TryCopyKey_KeySpace_Works() + { + var channel = RedisChannel.Literal("__keyspace@1__:testkey"); + RedisValue value = "set"; + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + // Test byte copy + Span byteBuffer = stackalloc byte[20]; + Assert.True(notification.TryCopyKey(byteBuffer, out var bytesWritten)); + Assert.Equal(7, bytesWritten); + Assert.Equal("testkey", Encoding.UTF8.GetString(byteBuffer.Slice(0, bytesWritten))); + + // Test char copy + Span charBuffer = stackalloc char[20]; + Assert.True(notification.TryCopyKey(charBuffer, out var charsWritten)); + Assert.Equal(7, charsWritten); + Assert.Equal("testkey", new string(charBuffer.Slice(0, charsWritten).ToArray())); + } + + [Fact] + public void TryCopyKey_KeyEvent_Works() + { + var channel = RedisChannel.Literal("__keyevent@1__:set"); + RedisValue value = "testkey"; + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + // Test byte copy + Span byteBuffer = stackalloc byte[20]; + Assert.True(notification.TryCopyKey(byteBuffer, out var bytesWritten)); + Assert.Equal(7, bytesWritten); + Assert.Equal("testkey", Encoding.UTF8.GetString(byteBuffer.Slice(0, bytesWritten))); + + // Test char copy + Span charBuffer = stackalloc char[20]; + Assert.True(notification.TryCopyKey(charBuffer, out var charsWritten)); + Assert.Equal(7, charsWritten); + Assert.Equal("testkey", new string(charBuffer.Slice(0, charsWritten).ToArray())); + } + + [Fact] + public void TryCopyKey_SubKeySpace_Works() + { + var channel = RedisChannel.Literal("__subkeyspace@1__:mykey"); + RedisValue value = "hset|6:field1"; + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + // Test byte copy + Span byteBuffer = stackalloc byte[20]; + Assert.True(notification.TryCopyKey(byteBuffer, out var bytesWritten)); + Assert.Equal(5, bytesWritten); + Assert.Equal("mykey", Encoding.UTF8.GetString(byteBuffer.Slice(0, bytesWritten))); + + // Test char copy + Span charBuffer = stackalloc char[20]; + Assert.True(notification.TryCopyKey(charBuffer, out var charsWritten)); + Assert.Equal(5, charsWritten); + Assert.Equal("mykey", new string(charBuffer.Slice(0, charsWritten).ToArray())); + } + + [Fact] + public void TryCopyKey_SubKeyEvent_Works() + { + var channel = RedisChannel.Literal("__subkeyevent@1__:hset"); + RedisValue value = "5:mykey|6:field1"; + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + // Test byte copy + Span byteBuffer = stackalloc byte[20]; + Assert.True(notification.TryCopyKey(byteBuffer, out var bytesWritten)); + Assert.Equal(5, bytesWritten); + Assert.Equal("mykey", Encoding.UTF8.GetString(byteBuffer.Slice(0, bytesWritten))); + + // Test char copy + Span charBuffer = stackalloc char[20]; + Assert.True(notification.TryCopyKey(charBuffer, out var charsWritten)); + Assert.Equal(5, charsWritten); + Assert.Equal("mykey", new string(charBuffer.Slice(0, charsWritten).ToArray())); + } + + [Fact] + public void TryCopyKey_SubKeySpaceItem_Works() + { + var channel = RedisChannel.Literal("__subkeyspaceitem@1__:mykey\nfield1"); + RedisValue value = "hset"; + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + // Test byte copy + Span byteBuffer = stackalloc byte[20]; + Assert.True(notification.TryCopyKey(byteBuffer, out var bytesWritten)); + Assert.Equal(5, bytesWritten); + Assert.Equal("mykey", Encoding.UTF8.GetString(byteBuffer.Slice(0, bytesWritten))); + + // Test char copy + Span charBuffer = stackalloc char[20]; + Assert.True(notification.TryCopyKey(charBuffer, out var charsWritten)); + Assert.Equal(5, charsWritten); + Assert.Equal("mykey", new string(charBuffer.Slice(0, charsWritten).ToArray())); + } + + [Fact] + public void TryCopyKey_SubKeySpaceEvent_Works() + { + var channel = RedisChannel.Literal("__subkeyspaceevent@1__:hset|mykey"); + RedisValue value = "6:field1"; + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + // Test byte copy + Span byteBuffer = stackalloc byte[20]; + Assert.True(notification.TryCopyKey(byteBuffer, out var bytesWritten)); + Assert.Equal(5, bytesWritten); + Assert.Equal("mykey", Encoding.UTF8.GetString(byteBuffer.Slice(0, bytesWritten))); + + // Test char copy + Span charBuffer = stackalloc char[20]; + Assert.True(notification.TryCopyKey(charBuffer, out var charsWritten)); + Assert.Equal(5, charsWritten); + Assert.Equal("mykey", new string(charBuffer.Slice(0, charsWritten).ToArray())); + } + + [Fact] + public void TryCopyKey_BufferTooSmall_ReturnsFalse() + { + var channel = RedisChannel.Literal("__keyspace@1__:verylongkeyname"); + RedisValue value = "set"; + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + // Test with buffer that's too small + Span tinyBuffer = stackalloc byte[5]; + Assert.False(notification.TryCopyKey(tinyBuffer, out var bytesWritten)); + Assert.Equal(15, bytesWritten); // Should report the actual size needed + + // Test char buffer too small + Span tinyCharBuffer = stackalloc char[5]; + Assert.False(notification.TryCopyKey(tinyCharBuffer, out var charsWritten)); + } + + [Fact] + public void SubKey_SubKeySpace_SubkeyNotAffectedByKeyPrefix() + { + // Test that subkey contains its own prefix and is not affected by the key prefix + var channel = RedisChannel.Literal("__subkeyspace@1__:user:123"); + RedisValue value = "hset|12:email:123456"; // subkey has different prefix "email:" + ReadOnlySpan keyPrefix = "user:"u8; // key prefix is "user:" + + Assert.True(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); + + // The key should have the "user:" prefix stripped + Assert.Equal("123", (string?)notification.GetKey()); + + // The subkey should be returned as-is with its own "email:" prefix intact + var subkey = notification.GetSubKey(); + Assert.Equal("email:123456", (string?)subkey); + Assert.Equal(12, subkey.GetByteCount()); + } + + [Fact] + public void SubKey_SubKeyEvent_SubkeyNotAffectedByKeyPrefix() + { + // Test that subkey is independent of key prefix + var channel = RedisChannel.Literal("__subkeyevent@1__:hset"); + RedisValue value = "8:user:123|12:email:123456"; // key has "user:" prefix, subkey has "email:" prefix + ReadOnlySpan keyPrefix = "user:"u8; + + Assert.True(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); + + // The key should have the "user:" prefix stripped + Assert.Equal("123", (string?)notification.GetKey()); + + // The subkey should be returned as-is with its own "email:" prefix intact + var subkey = notification.GetSubKey(); + Assert.Equal("email:123456", (string?)subkey); + Assert.Equal(12, subkey.GetByteCount()); + } + + [Fact] + public void SubKey_SubKeySpaceItem_SubkeyNotAffectedByKeyPrefix() + { + // Test that subkey in channel is independent of key prefix + var channel = RedisChannel.Literal("__subkeyspaceitem@1__:user:123\nemail:123456"); + RedisValue value = "hset"; + ReadOnlySpan keyPrefix = "user:"u8; + + Assert.True(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); + + // The key should have the "user:" prefix stripped + Assert.Equal("123", (string?)notification.GetKey()); + + // The subkey should be returned as-is with its own "email:" prefix intact + var subkey = notification.GetSubKey(); + Assert.Equal("email:123456", (string?)subkey); + } + + [Fact] + public void SubKey_SubKeySpaceEvent_SubkeyNotAffectedByKeyPrefix() + { + // Test that subkey in payload is independent of key prefix + var channel = RedisChannel.Literal("__subkeyspaceevent@1__:hset|user:123"); + RedisValue value = "12:email:123456"; + ReadOnlySpan keyPrefix = "user:"u8; + + Assert.True(KeyNotification.TryParse(keyPrefix, in channel, in value, out var notification)); + + // The key should have the "user:" prefix stripped + Assert.Equal("123", (string?)notification.GetKey()); + + // The subkey should be returned as-is with its own "email:" prefix intact + var subkey = notification.GetSubKey(); + Assert.Equal("email:123456", (string?)subkey); + Assert.Equal(12, subkey.GetByteCount()); + } } From 53ccd0c4c6c0303597a988716e38f4cf0979eae9 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 23 Apr 2026 12:33:36 +0100 Subject: [PATCH 03/14] docs, simplify usage --- docs/KeyspaceNotifications.md | 91 +++++++++++- src/StackExchange.Redis/KeyNotification.cs | 13 ++ .../PublicAPI/PublicAPI.Unshipped.txt | 4 + src/StackExchange.Redis/RedisChannel.cs | 80 ++++++++++- .../KeyNotificationTests.cs | 133 ++++++++++++++++++ .../PubSubKeyNotificationTests.cs | 10 ++ 6 files changed, 324 insertions(+), 7 deletions(-) diff --git a/docs/KeyspaceNotifications.md b/docs/KeyspaceNotifications.md index d9c4f26a1..e7514545a 100644 --- a/docs/KeyspaceNotifications.md +++ b/docs/KeyspaceNotifications.md @@ -21,6 +21,21 @@ notify-keyspace-events AKE 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. +**From Redis 8.8**, you can also optionally enable sub-key (hash field) notifications, using additional tokens: + +``` conf +notify-keyspace-events AKESTIV +``` + +- **S** - SubKeySpace notifications (`__subkeyspace@__:`) +- **T** - SubKeyEvent notifications (`__subkeyevent@__:`) +- **I** - SubKeySpaceItem notifications (`__subkeyspaceitem@__:\n`) +- **V** - SubKeySpaceEvent notifications (`__subkeyspaceevent@__:|`) + +These sub-key notification types allow you to monitor operations on hash fields (subkeys) in addition to key-level operations. +The different formats provide the same information but organized differently, and StackExchange.Redis provides a unified API +via the same `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 @@ -48,6 +63,17 @@ Note that there are a range of other `KeySpace...` and `KeyEvent...` methods for 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"`. +**From Redis 8.8**, there are corresponding `SubKeySpace...` and `SubKeyEvent...` methods for sub-key (hash field) notifications: + +- `SubKeySpaceSingleKey` - subscribe to sub-key notifications for a single key in a specific database +- `SubKeySpacePattern` - subscribe to sub-key notifications for a key pattern, optionally in a specific database +- `SubKeySpacePrefix` - subscribe to sub-key notifications for all keys with a specific prefix, optionally in a specific database +- `SubKeySpaceItem` - subscribe to sub-key notifications for a specific key and field combination in a specific database +- `SubKeyEvent` - subscribe to sub-key notifications for a specific event type, optionally in a specific database +- `SubKeySpaceEvent` - subscribe to sub-key notifications for a specific event type and key, optionally in a specific database + +These work similarly to their key-level counterparts, but monitor hash field operations instead of key operations. + 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. @@ -79,6 +105,14 @@ sub.Subscribe(channel, (recvChannel, recvValue) => Console.WriteLine($"Key: {notification.GetKey()}"); Console.WriteLine($"Type: {notification.Type}"); Console.WriteLine($"Database: {notification.Database}"); + Console.WriteLine($"Kind: {notification.Kind}"); + + // For sub-key notifications (Redis 8.8+), you can access the subkey in a uniform way, + // regardless of the notification type + if (notification.HasSubKey) + { + Console.WriteLine($"SubKey: {notification.GetSubKey()}"); + } } }); ``` @@ -86,12 +120,12 @@ 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. +the `__keyspace@` and `__keyevent@` prefixes (and similarly for sub-key events). ## 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, +database, kind, 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 @@ -105,6 +139,51 @@ 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. +## Working with Sub-Key (Hash Field) Notifications + +**From Redis 8.8**, Redis supports notifications for hash field (sub-key) operations. These notifications provide +more granular monitoring of hash operations, allowing you to observe changes to individual hash fields rather than +just key-level operations. + +### Understanding Sub-Key Notification Types + +There are four sub-key notification kinds, analogous to the two key-level notification kinds: + +- **SubKeySpace** (`__subkeyspace@__:`) - Notifications for a specific hash key, with the event type and sub-key in the payload +- **SubKeyEvent** (`__subkeyevent@__:`) - Notifications for a specific event type, with the key and sub-key in the payload +- **SubKeySpaceItem** (`__subkeyspaceitem@__:\n`) - Notifications for a specific hash key and field combination +- **SubKeySpaceEvent** (`__subkeyspaceevent@__:|`) - Notifications for a specific event and key, with the sub-key in the payload + +In most cases, the application code already knows the kind of event being consumed, but if that logic is centralized, +you can determine the notification family using the `notification.Kind` property (which returns a +`KeyNotificationKind` enum value), and optionally extract the sub-key using `notification.GetSubKey()`. + +### Example: Monitoring Hash Field Changes + +```csharp +// Subscribe to all sub-key changes for hashes with prefix "user:" +var channel = RedisChannel.SubKeySpacePrefix("user:", database: 0); + +sub.Subscribe(channel, (recvChannel, recvValue) => +{ + if (KeyNotification.TryParse(recvChannel, recvValue, out var notification)) + { + Console.WriteLine($"Hash Key: {notification.GetKey()}"); + Console.WriteLine($"Field: {notification.GetSubKey()}"); + Console.WriteLine($"Operation: {notification.Type}"); + Console.WriteLine($"Kind: {notification.Kind}"); + } +}); + +// Or subscribe to specific hash field events (e.g., HSET operations) +var eventChannel = RedisChannel.SubKeyEvent(KeyNotificationType.HSet, database: 0); +``` + +### Sub-Key and Key Prefix Filtering + +When using key-prefix filtering with sub-key notifications, the prefix is applied to the **key** only, not to the +sub-key (hash field). The sub-key is always returned as-is from the notification, without any prefix stripping. + ## Considerations when using database isolation Database isolation is controlled either via the `ConfigurationOptions.DefaultDatabase` option when connecting to Redis, @@ -123,6 +202,14 @@ For example: - `RedisChannel.KeyEvent(KeyNotificationType.Set, 0)` maps to `SUBSCRIBE __keyevent@0__:set` - `RedisChannel.KeyEvent(KeyNotificationType.Set)` maps to `PSUBSCRIBE __keyevent@*__:set` +**From Redis 8.8**, the sub-key notification methods work similarly: + +- `RedisChannel.SubKeySpaceSingleKey("myhash", 0)` maps to `SUBSCRIBE __subkeyspace@0__:myhash` +- `RedisChannel.SubKeySpacePrefix("hash:", 0)` maps to `PSUBSCRIBE __subkeyspace@0__:hash:*` +- `RedisChannel.SubKeySpaceItem("myhash", "field1", 0)` maps to `SUBSCRIBE __subkeyspaceitem@0__:myhash\nfield1` +- `RedisChannel.SubKeyEvent(KeyNotificationType.HSet, 0)` maps to `SUBSCRIBE __subkeyevent@0__:hset` +- `RedisChannel.SubKeySpaceEvent(KeyNotificationType.HSet, "myhash", 0)` maps to `SUBSCRIBE __subkeyspaceevent@0__:hset|myhash` + 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`. diff --git a/src/StackExchange.Redis/KeyNotification.cs b/src/StackExchange.Redis/KeyNotification.cs index 574488b9e..f68b3cc9b 100644 --- a/src/StackExchange.Redis/KeyNotification.cs +++ b/src/StackExchange.Redis/KeyNotification.cs @@ -77,6 +77,19 @@ public readonly ref struct KeyNotification /// public KeyNotificationKind Kind => _kind; + /// + /// Indicates whether this notification includes a sub-key (hash field). + /// + /// This is true for SubKeySpace, SubKeyEvent, SubKeySpaceItem, and SubKeySpaceEvent notifications (Redis 8.8+). + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public bool HasSubKey + { + get => _kind is KeyNotificationKind.SubKeySpace + or KeyNotificationKind.SubKeyEvent + or KeyNotificationKind.SubKeySpaceItem + or KeyNotificationKind.SubKeySpaceEvent; + } + /// /// If the channel is a keyspace, keyevent, subkeyspace, subkeyevent, subkeyspaceitem, or subkeyeventitem notification, resolve the key and event type. /// diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 77f991f09..2fd5ab1b9 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,11 +1,15 @@ #nullable enable [SER006]static StackExchange.Redis.RedisChannel.SubKeyEvent(StackExchange.Redis.KeyNotificationType type, int? database = null) -> StackExchange.Redis.RedisChannel [SER006]static StackExchange.Redis.RedisChannel.SubKeyEvent(System.ReadOnlySpan type, int? database) -> StackExchange.Redis.RedisChannel +[SER006]static StackExchange.Redis.RedisChannel.SubKeySpaceEvent(StackExchange.Redis.KeyNotificationType type, in StackExchange.Redis.RedisKey key, int? database = null) -> StackExchange.Redis.RedisChannel +[SER006]static StackExchange.Redis.RedisChannel.SubKeySpaceEvent(System.ReadOnlySpan type, in StackExchange.Redis.RedisKey key, int? database) -> StackExchange.Redis.RedisChannel +[SER006]static StackExchange.Redis.RedisChannel.SubKeySpaceItem(in StackExchange.Redis.RedisKey key, in StackExchange.Redis.RedisKey subkey, int database) -> StackExchange.Redis.RedisChannel [SER006]static StackExchange.Redis.RedisChannel.SubKeySpacePattern(in StackExchange.Redis.RedisKey pattern, int? database = null) -> StackExchange.Redis.RedisChannel [SER006]static StackExchange.Redis.RedisChannel.SubKeySpacePrefix(in StackExchange.Redis.RedisKey prefix, int? database = null) -> StackExchange.Redis.RedisChannel [SER006]static StackExchange.Redis.RedisChannel.SubKeySpacePrefix(System.ReadOnlySpan prefix, int? database = null) -> StackExchange.Redis.RedisChannel [SER006]static StackExchange.Redis.RedisChannel.SubKeySpaceSingleKey(in StackExchange.Redis.RedisKey key, int database) -> StackExchange.Redis.RedisChannel [SER006]StackExchange.Redis.KeyNotification.GetSubKey() -> StackExchange.Redis.RedisValue +StackExchange.Redis.KeyNotification.HasSubKey.get -> bool StackExchange.Redis.KeyNotification.Kind.get -> StackExchange.Redis.KeyNotificationKind StackExchange.Redis.KeyNotificationKind StackExchange.Redis.KeyNotificationKind.KeyEvent = 2 -> StackExchange.Redis.KeyNotificationKind diff --git a/src/StackExchange.Redis/RedisChannel.cs b/src/StackExchange.Redis/RedisChannel.cs index b0031986e..f670c8055 100644 --- a/src/StackExchange.Redis/RedisChannel.cs +++ b/src/StackExchange.Redis/RedisChannel.cs @@ -287,10 +287,10 @@ private static RedisChannel CreateKeyEvent(ReadOnlySpan type, int? databas if (database is null) options |= RedisChannelOptions.Pattern; var db = AppendDatabase(stackalloc byte[DatabaseScratchBufferSize], database, options); - // __keyevent@{db}__:{type} - var arr = new byte[14 + db.Length + type.Length]; + // __keyevent@{db}__:{type} or __subkeyevent@{db}__:{type} + var arr = new byte[(subkey ? 17 : 14) + db.Length + type.Length]; - var target = AppendAndAdvance(arr.AsSpan(), "__keyevent@"u8); + var target = AppendAndAdvance(arr.AsSpan(), subkey ? "__subkeyevent@"u8 : "__keyevent@"u8); target = AppendAndAdvance(target, db); target = AppendAndAdvance(target, "__:"u8); target = AppendAndAdvance(target, type); @@ -355,6 +355,76 @@ public static RedisChannel SubKeyEvent(KeyNotificationType type, int? database = public static RedisChannel SubKeyEvent(ReadOnlySpan type, int? database) => CreateKeyEvent(type, database, subkey: true); + /// + /// Create a subkey (hash) notification channel for a specific key and subkey in a single database. + /// + /// Format: __subkeyspaceitem@{db}__:{key}\n{subkey}. + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public static RedisChannel SubKeySpaceItem(in RedisKey key, in RedisKey subkey, int database) + { + if (key.IsEmpty) throw new ArgumentNullException(nameof(key)); + if (subkey.IsEmpty) throw new ArgumentNullException(nameof(subkey)); + + var db = AppendDatabase(stackalloc byte[DatabaseScratchBufferSize], database, RedisChannelOptions.None); + + // __subkeyspaceitem@{db}__:{key}\n{subkey} + var arr = new byte[21 + db.Length + key.TotalLength() + 1 + subkey.TotalLength()]; + + var target = AppendAndAdvance(arr.AsSpan(), "__subkeyspaceitem@"u8); + target = AppendAndAdvance(target, db); + target = AppendAndAdvance(target, "__:"u8); + var keyLen = key.CopyTo(target); + target = target.Slice(keyLen); + target[0] = (byte)'\n'; + target = target.Slice(1); + var subkeyLen = subkey.CopyTo(target); + target = target.Slice(subkeyLen); + Debug.Assert(target.IsEmpty, "length calculated incorrectly"); + + return new RedisChannel(arr, RedisChannelOptions.KeyRouted | RedisChannelOptions.IgnoreChannelPrefix); + } + + /// + /// Create a subkey (hash) event-notification channel for a specific event type and key, optionally in a specified database. + /// + /// Format: __subkeyspaceevent@{db}__:{event}|{key}. +#pragma warning disable RS0027 // competing overloads - disambiguated via parameter types + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public static RedisChannel SubKeySpaceEvent(KeyNotificationType type, in RedisKey key, int? database = null) +#pragma warning restore RS0027 + => SubKeySpaceEvent(KeyNotificationTypeMetadata.GetRawBytes(type), key, database); + + /// + /// Create a subkey (hash) event-notification channel for a specific event type and key, optionally in a specified database. + /// + /// This API is intended for use with custom/unknown event types; for well-known types, use . + /// Format: __subkeyspaceevent@{db}__:{event}|{key}. + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public static RedisChannel SubKeySpaceEvent(ReadOnlySpan type, in RedisKey key, int? database) + { + if (type.IsEmpty) throw new ArgumentNullException(nameof(type)); + if (key.IsEmpty) throw new ArgumentNullException(nameof(key)); + + RedisChannelOptions options = RedisChannelOptions.MultiNode; + if (database is null) options |= RedisChannelOptions.Pattern; + var db = AppendDatabase(stackalloc byte[DatabaseScratchBufferSize], database, options); + + // __subkeyspaceevent@{db}__:{event}|{key} + var arr = new byte[22 + db.Length + type.Length + 1 + key.TotalLength()]; + + var target = AppendAndAdvance(arr.AsSpan(), "__subkeyspaceevent@"u8); + target = AppendAndAdvance(target, db); + target = AppendAndAdvance(target, "__:"u8); + target = AppendAndAdvance(target, type); + target[0] = (byte)'|'; + target = target.Slice(1); + var keyLen = key.CopyTo(target); + target = target.Slice(keyLen); + Debug.Assert(target.IsEmpty, "length calculated incorrectly"); + + return new RedisChannel(arr, options | RedisChannelOptions.IgnoreChannelPrefix); + } + private static Span AppendAndAdvance(Span target, scoped ReadOnlySpan value) { value.CopyTo(target); @@ -369,8 +439,8 @@ private static RedisChannel BuildKeySpaceChannel(in RedisKey key, int? database, var db = AppendDatabase(stackalloc byte[DatabaseScratchBufferSize], database, options); - // __keyspace@{db}__:{key}[*] - var arr = new byte[14 + db.Length + fullKeyLength]; + // __keyspace@{db}__:{key}[*] or __subkeyspace@{db}__:{key}[*] + var arr = new byte[(subkey ? 17 : 14) + db.Length + fullKeyLength]; var target = AppendAndAdvance(arr.AsSpan(), subkey ? "__subkeyspace@"u8 : "__keyspace@"u8); target = AppendAndAdvance(target, db); diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs index 60cbec3e3..166852821 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs @@ -604,6 +604,139 @@ public void CreateKeyEventNotification(KeyNotificationType type, int? database, } } + [Fact] + public void CreateSubKeySpaceNotification_Valid() + { + var channel = RedisChannel.SubKeySpaceSingleKey("myhash", 42); + Assert.Equal("__subkeyspace@42__:myhash", channel.ToString()); + Assert.False(channel.IsMultiNode); + Assert.True(channel.IsKeyRouted); + Assert.False(channel.IsSharded); + Assert.False(channel.IsPattern); + Assert.True(channel.IgnoreChannelPrefix); + } + + [Theory] + [InlineData(null, null, "__subkeyspace@*__:*")] + [InlineData("hash*", null, "__subkeyspace@*__:hash*")] + [InlineData(null, 42, "__subkeyspace@42__:*")] + [InlineData("hash*", 42, "__subkeyspace@42__:hash*")] + public void CreateSubKeySpaceNotificationPattern(string? pattern, int? database, string expected) + { + var channel = RedisChannel.SubKeySpacePattern(pattern, 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("hash:", null, "__subkeyspace@*__:hash:*")] + [InlineData("hash:", 42, "__subkeyspace@42__:hash:*")] + public void CreateSubKeySpaceNotificationPrefix_Key(string prefix, int? database, string expected) + { + var channel = RedisChannel.SubKeySpacePrefix((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("hash:", null, "__subkeyspace@*__:hash:*")] + [InlineData("hash:", 42, "__subkeyspace@42__:hash:*")] + public void CreateSubKeySpaceNotificationPrefix_Span(string prefix, int? database, string expected) + { + var channel = RedisChannel.SubKeySpacePrefix((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("hash?", null)] + [InlineData("hash?", 42)] + [InlineData("hash*", null)] + [InlineData("hash*", 42)] + [InlineData("hash[", null)] + [InlineData("hash[", 42)] + public void CreateSubKeySpaceNotificationPrefix_DisallowGlob(string prefix, int? database) + { + var bytes = Encoding.UTF8.GetBytes(prefix); + var ex = Assert.Throws(() => + RedisChannel.SubKeySpacePrefix((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.SubKeySpacePrefix((ReadOnlySpan)bytes, database)); + Assert.StartsWith("The supplied key contains pattern characters, but patterns are not supported in this context.", ex.Message); + } + + [Theory] + [InlineData(KeyNotificationType.HSet, null, "__subkeyevent@*__:hset", true)] + [InlineData(KeyNotificationType.HDel, null, "__subkeyevent@*__:hdel", true)] + [InlineData(KeyNotificationType.HSet, 42, "__subkeyevent@42__:hset", false)] + [InlineData(KeyNotificationType.HDel, 42, "__subkeyevent@42__:hdel", false)] + public void CreateSubKeyEventNotification(KeyNotificationType type, int? database, string expected, bool isPattern) + { + var channel = RedisChannel.SubKeyEvent(type, database); + Assert.Equal(expected, channel.ToString()); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsKeyRouted); + Assert.False(channel.IsSharded); + Assert.True(channel.IgnoreChannelPrefix); + if (isPattern) + { + Assert.True(channel.IsPattern); + } + else + { + Assert.False(channel.IsPattern); + } + } + + [Fact] + public void CreateSubKeySpaceItemNotification_Valid() + { + var channel = RedisChannel.SubKeySpaceItem("myhash", "field1", 42); + Assert.Equal("__subkeyspaceitem@42__:myhash\nfield1", channel.ToString()); + Assert.False(channel.IsMultiNode); + Assert.True(channel.IsKeyRouted); + Assert.False(channel.IsSharded); + Assert.False(channel.IsPattern); + Assert.True(channel.IgnoreChannelPrefix); + } + + [Theory] + [InlineData(KeyNotificationType.HSet, "myhash", null, "__subkeyspaceevent@*__:hset|myhash", true)] + [InlineData(KeyNotificationType.HDel, "myhash", null, "__subkeyspaceevent@*__:hdel|myhash", true)] + [InlineData(KeyNotificationType.HSet, "myhash", 42, "__subkeyspaceevent@42__:hset|myhash", false)] + [InlineData(KeyNotificationType.HDel, "myhash", 42, "__subkeyspaceevent@42__:hdel|myhash", false)] + public void CreateSubKeySpaceEventNotification(KeyNotificationType type, string key, int? database, string expected, bool isPattern) + { + var channel = RedisChannel.SubKeySpaceEvent(type, key, database); + Assert.Equal(expected, channel.ToString()); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsKeyRouted); + Assert.False(channel.IsSharded); + Assert.True(channel.IgnoreChannelPrefix); + if (isPattern) + { + Assert.True(channel.IsPattern); + } + else + { + Assert.False(channel.IsPattern); + } + } + [Theory] [InlineData("abc", "__keyspace@42__:abc")] [InlineData("a*bc", "__keyspace@42__:a*bc")] // pattern-like is allowed, since not using PSUBSCRIBE diff --git a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs index a65d0c631..0fe90cb89 100644 --- a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs @@ -142,7 +142,9 @@ await sub.SubscribeAsync(channel, (recvChannel, recvValue) => { callbackCount.Increment(); if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) +#pragma warning disable CS0618 // Type or member is obsolete && notification is { IsKeyEvent: true, Type: KeyNotificationType.SAdd }) +#pragma warning restore CS0618 // Type or member is obsolete { OnNotification(notification, prefix, matchingEventCount, observedCounts, allDone); } @@ -183,7 +185,9 @@ public async Task KeyEvent_CanObserveSimple_ViaQueue(bool withChannelPrefix) { callbackCount.Increment(); if (msg.TryParseKeyNotification(out var notification) +#pragma warning disable CS0618 // Type or member is obsolete && notification is { IsKeyEvent: true, Type: KeyNotificationType.SAdd }) +#pragma warning restore CS0618 // Type or member is obsolete { OnNotification(notification, prefix, matchingEventCount, observedCounts, allDone); } @@ -225,7 +229,9 @@ public async Task KeyNotification_CanObserveSimple_ViaCallbackHandler(bool withC { callbackCount.Increment(); if (msg.TryParseKeyNotification(out var notification) +#pragma warning disable CS0618 // Type or member is obsolete && notification is { IsKeySpace: true, Type: KeyNotificationType.SAdd }) +#pragma warning restore CS0618 // Type or member is obsolete { OnNotification(notification, prefix, matchingEventCount, observedCounts, allDone); } @@ -264,7 +270,9 @@ await sub.SubscribeAsync(channel, (recvChannel, recvValue) => { callbackCount.Increment(); if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) +#pragma warning disable CS0618 // Type or member is obsolete && notification is { IsKeySpace: true, Type: KeyNotificationType.SAdd }) +#pragma warning restore CS0618 // Type or member is obsolete { OnNotification(notification, prefix, matchingEventCount, observedCounts, allDone); } @@ -312,7 +320,9 @@ public async Task KeyNotification_CanObserveSingleKey_ViaQueue(bool withChannelP { callbackCount.Increment(); if (msg.TryParseKeyNotification(keyPrefixBytes, out var notification) +#pragma warning disable CS0618 // Type or member is obsolete && notification is { IsKeySpace: true, Type: KeyNotificationType.SAdd }) +#pragma warning restore CS0618 // Type or member is obsolete { OnNotification(notification, prefix, matchingEventCount, observedCounts, allDone); } From 8cffce1635a99b4b48641abc4aee8d97e7217723 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 23 Apr 2026 14:34:29 +0100 Subject: [PATCH 04/14] subkeys API --- docs/KeyspaceNotifications.md | 30 +- .../KeyNotification.SubKeys.cs | 348 ++++++++++++++++++ src/StackExchange.Redis/KeyNotification.cs | 86 +---- .../PublicAPI/PublicAPI.Unshipped.txt | 19 +- .../KeyNotificationTests.cs | 316 ++++++++++++++-- 5 files changed, 698 insertions(+), 101 deletions(-) create mode 100644 src/StackExchange.Redis/KeyNotification.SubKeys.cs diff --git a/docs/KeyspaceNotifications.md b/docs/KeyspaceNotifications.md index e7514545a..ac657d0ee 100644 --- a/docs/KeyspaceNotifications.md +++ b/docs/KeyspaceNotifications.md @@ -107,11 +107,18 @@ sub.Subscribe(channel, (recvChannel, recvValue) => Console.WriteLine($"Database: {notification.Database}"); Console.WriteLine($"Kind: {notification.Kind}"); - // For sub-key notifications (Redis 8.8+), you can access the subkey in a uniform way, + // For sub-key notifications (Redis 8.8+), you can access sub-keys in a uniform way, // regardless of the notification type if (notification.HasSubKey) { - Console.WriteLine($"SubKey: {notification.GetSubKey()}"); + // Get the first sub-key + Console.WriteLine($"First SubKey: {notification.GetSubKeys().First()}"); + + // Or iterate all sub-keys (for notifications with multiple fields) + foreach (var subKey in notification.GetSubKeys()) + { + Console.WriteLine($"SubKey: {subKey}"); + } } } }); @@ -156,7 +163,7 @@ There are four sub-key notification kinds, analogous to the two key-level notifi In most cases, the application code already knows the kind of event being consumed, but if that logic is centralized, you can determine the notification family using the `notification.Kind` property (which returns a -`KeyNotificationKind` enum value), and optionally extract the sub-key using `notification.GetSubKey()`. +`KeyNotificationKind` enum value), and optionally extract sub-keys using `notification.GetSubKeys()`. ### Example: Monitoring Hash Field Changes @@ -169,9 +176,24 @@ sub.Subscribe(channel, (recvChannel, recvValue) => if (KeyNotification.TryParse(recvChannel, recvValue, out var notification)) { Console.WriteLine($"Hash Key: {notification.GetKey()}"); - Console.WriteLine($"Field: {notification.GetSubKey()}"); Console.WriteLine($"Operation: {notification.Type}"); Console.WriteLine($"Kind: {notification.Kind}"); + + // Process all affected fields + foreach (var field in notification.GetSubKeys()) + { + Console.WriteLine($"Field: {field}"); + } + + // Or get just the first field for single-field operations + var firstField = notification.GetSubKeys().FirstOrDefault(); + + // Utility methods available: + // - Count() - get the number of fields + // - First() / FirstOrDefault() - get the first field + // - Single() / SingleOrDefault() - get the only field (throws if multiple) + // - ToArray() / ToList() - convert to collection + // - CopyTo(Span) - copy to a span (allocation-free) } }); diff --git a/src/StackExchange.Redis/KeyNotification.SubKeys.cs b/src/StackExchange.Redis/KeyNotification.SubKeys.cs new file mode 100644 index 000000000..3f099860a --- /dev/null +++ b/src/StackExchange.Redis/KeyNotification.SubKeys.cs @@ -0,0 +1,348 @@ +using System; +using System.Buffers; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using RESPite; + +namespace StackExchange.Redis; + +public readonly ref partial struct KeyNotification +{ + /// + /// Gets all sub-keys in this notification. For notifications without sub-keys, returns an empty enumerable. + /// + /// This method is available for SubKeySpace, SubKeyEvent, SubKeySpaceItem, and SubKeySpaceEvent notification types. + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public SubKeyEnumerable GetSubKeys() + { + return new SubKeyEnumerable(this); + } + + /// + /// Provides enumeration over sub-keys in a keyspace notification. + /// + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public readonly ref struct SubKeyEnumerable + { + private static readonly RedisValue[] Empty = []; + private static readonly RedisValue[] Multiple = [RedisValue.Null, RedisValue.Null]; + private readonly KeyNotification _notification; + + internal SubKeyEnumerable(KeyNotification notification) + { + _notification = notification; + } + + /// + /// Gets an enumerator for the sub-keys. + /// + public SubKeyEnumerator GetEnumerator() => new SubKeyEnumerator(_notification); + + /// + /// Gets the number of sub-keys in this notification. + /// + public int Count() + { + var count = 0; + using var enumerator = GetEnumerator(); + while (enumerator.TryMoveNext(setCurrent: false)) + { + count++; + } + return count; + } + + /// + /// Gets the first sub-key in this notification. + /// + /// The sequence contains no elements. + public RedisValue First() + { + foreach (var subKey in this) + { + return subKey; + } + return Empty.First(); // for error consistency + } + + /// + /// Gets the first sub-key in this notification, or a null value if the sequence is empty. + /// + public RedisValue FirstOrDefault() + { + foreach (var subKey in this) + { + return subKey; + } + return RedisValue.Null; + } + + /// + /// Gets the only sub-key in this notification. + /// + /// The sequence contains no elements, or more than one element. + public RedisValue Single() + { + using var enumerator = GetEnumerator(); + if (!enumerator.MoveNext()) + { + return Empty.Single(); // for error consistency + } + var result = enumerator.Current; + if (enumerator.MoveNext()) + { + return Multiple.Single(); // for error consistency + } + return result; + } + + /// + /// Gets the only sub-key in this notification, or a null value if the sequence is empty. + /// + /// The sequence contains more than one element. + public RedisValue SingleOrDefault() + { + using var enumerator = GetEnumerator(); + if (!enumerator.MoveNext()) + { + return RedisValue.Null; + } + var result = enumerator.Current; + if (enumerator.MoveNext()) + { + return Multiple.Single(); // for error consistency + } + return result; + } + + /// + /// Tries to copy all sub-keys to the specified span. + /// + /// The span to copy sub-keys into. + /// The number of sub-keys copied. + /// true if all sub-keys were copied; false if the destination was too small (partial copy). + public bool TryCopyTo(Span destination, out int count) + { + count = 0; + foreach (var subKey in this) + { + if (count >= destination.Length) + { + return false; // Destination too small, partial copy + } + destination[count++] = subKey; + } + return true; // All sub-keys copied + } + + /// + /// Copies all sub-keys to the specified span. + /// + /// The span to copy sub-keys into. + /// The number of sub-keys copied. If the destination is too small, only as many sub-keys as will fit are copied. + public int CopyTo(Span destination) + { + _ = TryCopyTo(destination, out var count); + return count; + } + + /// + /// Converts the sub-keys to an array. + /// + public RedisValue[] ToArray() + { + var count = Count(); + if (count == 0) return Empty; + + var array = new RedisValue[count]; + var index = 0; + foreach (var subKey in this) + { + array[index++] = subKey; + } + return array; + } + + /// + /// Converts the sub-keys to a list. + /// + public List ToList() + { + var count = Count(); + var list = new List(count); + foreach (var subKey in this) + { + list.Add(subKey); + } + return list; + } + } + + /// + /// Enumerator for sub-keys in a keyspace notification. + /// + [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] + public ref struct SubKeyEnumerator + { + private readonly KeyNotificationKind _kind; + private ReadOnlySpan _data; + private byte[]? _lease; + private int _position; + private RedisValue _current; + + internal SubKeyEnumerator(scoped KeyNotification notification) + { + _kind = notification._kind; + _lease = null; + _position = 0; + _current = default; + + // Always copy the relevant data to a leased buffer to avoid lifetime issues + switch (_kind) + { + case KeyNotificationKind.SubKeySpace: + case KeyNotificationKind.SubKeyEvent: + // Payload: |:[|:...] or :|:[|:...] + // We need to skip to the first | and then iterate through length-prefixed subkeys + _data = CopyAndLeaseValue(notification._value, out _lease); + + // Find the first pipe to skip the event/key part + var firstPipe = _data.IndexOf((byte)'|'); + if (firstPipe >= 0 && firstPipe + 1 < _data.Length) + { + _position = firstPipe + 1; // Start after the first | + } + else + { + _position = _data.Length; // No subkeys + } + break; + + case KeyNotificationKind.SubKeySpaceItem: + // Channel: __subkeyspaceitem@__:\n + // Single subkey only - extract from channel suffix after \n + var suffix = notification.ChannelSuffix; + var newlineIndex = suffix.IndexOf((byte)'\n'); + if (newlineIndex >= 0 && newlineIndex + 1 < suffix.Length) + { + // Copy the subkey part to a leased buffer + var subkeySpan = suffix.Slice(newlineIndex + 1); + var buffer = _lease = ArrayPool.Shared.Rent(subkeySpan.Length); + subkeySpan.CopyTo(buffer); + _data = buffer.AsSpan(0, subkeySpan.Length); + _position = 0; // Will return this single value + } + else + { + _data = default; + _position = 0; + } + break; + + case KeyNotificationKind.SubKeySpaceEvent: + // Payload: :[|:...] + _data = CopyAndLeaseValue(notification._value, out _lease); + _position = 0; + break; + + default: + // No subkeys for other notification types + _data = default; + _position = 0; + break; + } + } + + private static ReadOnlySpan CopyAndLeaseValue(RedisValue value, out byte[] lease) + { + // Always lease a buffer and copy - we can't return a span directly from RedisValue + // because it may reference data in the notification parameter which has limited lifetime + var byteCount = value.GetByteCount(); + var buffer = lease = ArrayPool.Shared.Rent(byteCount); + var written = value.CopyTo(buffer); + return buffer.AsSpan(0, written); + } + + /// + /// Gets the current sub-key. + /// + public RedisValue Current => _current; + + /// + /// Advances to the next sub-key. + /// + public bool MoveNext() => TryMoveNext(setCurrent: true); + + internal bool TryMoveNext(bool setCurrent) + { + if (_position >= _data.Length) + { + return false; + } + + switch (_kind) + { + case KeyNotificationKind.SubKeySpaceItem: + // Single subkey - return it once + if (_position == 0) + { + if (setCurrent) + { + _current = _data.ToArray(); + } + _position = _data.Length; // Mark as consumed + return true; + } + return false; + + case KeyNotificationKind.SubKeySpace: + case KeyNotificationKind.SubKeyEvent: + case KeyNotificationKind.SubKeySpaceEvent: + // Length-prefixed format: : + var value = KeyNotification.ExtractLengthPrefixedValue(_data.Slice(_position)); + if (value.IsNull) + { + return false; + } + + if (setCurrent) + { + _current = value; + } + + // Move position forward: skip the length prefix + colon + value + pipe (if present) + var remaining = _data.Slice(_position); + var colonIndex = remaining.IndexOf((byte)':'); + if (colonIndex < 0) return false; + + var valueLength = (int)value.Length(); + _position += colonIndex + 1 + valueLength; + + // Skip the | separator if present + if (_position < _data.Length && _data[_position] == (byte)'|') + { + _position++; + } + + return true; + + default: + return false; + } + } + + /// + /// Releases any leased buffers. + /// + public void Dispose() + { + if (_lease is not null) + { + ArrayPool.Shared.Return(_lease); + _lease = null; + } + } + } +} diff --git a/src/StackExchange.Redis/KeyNotification.cs b/src/StackExchange.Redis/KeyNotification.cs index f68b3cc9b..ced4016e9 100644 --- a/src/StackExchange.Redis/KeyNotification.cs +++ b/src/StackExchange.Redis/KeyNotification.cs @@ -60,7 +60,7 @@ public enum KeyNotificationKind /// 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 ref struct KeyNotification +public readonly ref partial struct KeyNotification { // effectively we just wrap a channel, but: we've pre-validated that things make sense private readonly RedisChannel _channel; @@ -323,58 +323,6 @@ public RedisKey GetKey() } } - /// - /// The subkey associated with this event, if applicable. Returns for non-subkey notification types. - /// - /// For notifications with multiple subkeys, only the first subkey is returned. - [Experimental(Experiments.Server_8_8, UrlFormat = Experiments.UrlFormat)] - public RedisValue GetSubKey() - { - switch (_kind) - { - case KeyNotificationKind.SubKeySpace: - case KeyNotificationKind.SubKeyEvent: - // Payload contains |: or :|: - if (_value.TryGetSpan(out var span)) - { - var pipeIndex = span.IndexOf((byte)'|'); - if (pipeIndex >= 0 && pipeIndex + 1 < span.Length) - { - return ExtractLengthPrefixedValue(span.Slice(pipeIndex + 1)); - } - } - else - { - // Fallback for non-contiguous values - Span buffer = stackalloc byte[256]; - var bytesWritten = _value.CopyTo(buffer); - var pipeIndex = buffer.Slice(0, bytesWritten).IndexOf((byte)'|'); - if (pipeIndex >= 0 && pipeIndex + 1 < bytesWritten) - { - return ExtractLengthPrefixedValue(buffer.Slice(pipeIndex + 1, bytesWritten - pipeIndex - 1)); - } - } - return RedisValue.Null; - - case KeyNotificationKind.SubKeySpaceItem: - // __subkeyspaceitem@__:\n with payload - var suffix = ChannelSuffix; - var newlineIndex = suffix.IndexOf((byte)'\n'); - if (newlineIndex >= 0 && newlineIndex + 1 < suffix.Length) - { - return suffix.Slice(newlineIndex + 1).ToArray(); - } - return RedisValue.Null; - - case KeyNotificationKind.SubKeySpaceEvent: - // __subkeyspaceevent@__:| with payload :[,...] - return ExtractLengthPrefixedValue(_value, 0); - - default: - return RedisValue.Null; - } - } - // Helper to extract a value prefixed with its length, e.g., "5:hello" -> "hello" internal static RedisValue ExtractLengthPrefixedValue(in RedisValue value, int offset) { @@ -856,27 +804,29 @@ public KeyNotificationType Type return KeyNotificationTypeMetadata.Parse(ChannelSuffix); case KeyNotificationKind.SubKeySpace: - // Payload contains |: + // Payload contains |:[|:...] if (_value.TryGetSpan(out var directSub)) { - var pipeIndex = directSub.IndexOf((byte)'|'); - if (pipeIndex > 0) + var pipeIndexSub = directSub.IndexOf((byte)'|'); + if (pipeIndexSub > 0) { - return KeyNotificationTypeMetadata.Parse(directSub.Slice(0, pipeIndex)); + return KeyNotificationTypeMetadata.Parse(directSub.Slice(0, pipeIndexSub)); } } - if (_value.GetByteCount() <= KeyNotificationTypeMetadata.BufferBytes) - { - Span localCopy = stackalloc byte[KeyNotificationTypeMetadata.BufferBytes]; - var len = _value.CopyTo(localCopy); - var pipeIndex = localCopy.Slice(0, len).IndexOf((byte)'|'); - if (pipeIndex > 0) - { - return KeyNotificationTypeMetadata.Parse(localCopy.Slice(0, pipeIndex)); - } - } - return KeyNotificationType.Unknown; + // Need to copy the value to find the pipe - the event type is before the first | + byte[]? leaseSub = null; + var byteCountSub = _value.GetByteCount(); + Span localCopySub = byteCountSub <= KeyNotificationTypeMetadata.BufferBytes + ? stackalloc byte[KeyNotificationTypeMetadata.BufferBytes] + : (leaseSub = ArrayPool.Shared.Rent(byteCountSub)); + var lenSub = _value.CopyTo(localCopySub); + var pipeIndexSub2 = localCopySub.Slice(0, lenSub).IndexOf((byte)'|'); + KeyNotificationType resultSub = pipeIndexSub2 > 0 + ? KeyNotificationTypeMetadata.Parse(localCopySub.Slice(0, pipeIndexSub2)) + : KeyNotificationType.Unknown; + if (leaseSub is not null) ArrayPool.Shared.Return(leaseSub); + return resultSub; case KeyNotificationKind.SubKeySpaceEvent: // Channel contains event|key - extract the event part before the pipe diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 2fd5ab1b9..f94217cd1 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -8,7 +8,24 @@ [SER006]static StackExchange.Redis.RedisChannel.SubKeySpacePrefix(in StackExchange.Redis.RedisKey prefix, int? database = null) -> StackExchange.Redis.RedisChannel [SER006]static StackExchange.Redis.RedisChannel.SubKeySpacePrefix(System.ReadOnlySpan prefix, int? database = null) -> StackExchange.Redis.RedisChannel [SER006]static StackExchange.Redis.RedisChannel.SubKeySpaceSingleKey(in StackExchange.Redis.RedisKey key, int database) -> StackExchange.Redis.RedisChannel -[SER006]StackExchange.Redis.KeyNotification.GetSubKey() -> StackExchange.Redis.RedisValue +[SER006]StackExchange.Redis.KeyNotification.GetSubKeys() -> StackExchange.Redis.KeyNotification.SubKeyEnumerable +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.SubKeyEnumerable() -> void +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.CopyTo(System.Span destination) -> int +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.Count() -> int +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.First() -> StackExchange.Redis.RedisValue +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.FirstOrDefault() -> StackExchange.Redis.RedisValue +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.GetEnumerator() -> StackExchange.Redis.KeyNotification.SubKeyEnumerator +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.Single() -> StackExchange.Redis.RedisValue +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.SingleOrDefault() -> StackExchange.Redis.RedisValue +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.ToArray() -> StackExchange.Redis.RedisValue[]! +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.ToList() -> System.Collections.Generic.List! +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.TryCopyTo(System.Span destination, out int count) -> bool +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.SubKeyEnumerator() -> void +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.Current.get -> StackExchange.Redis.RedisValue +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.Dispose() -> void +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.MoveNext() -> bool StackExchange.Redis.KeyNotification.HasSubKey.get -> bool StackExchange.Redis.KeyNotification.Kind.get -> StackExchange.Redis.KeyNotificationKind StackExchange.Redis.KeyNotificationKind diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs index 166852821..876bf64aa 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs @@ -406,7 +406,7 @@ public void DefaultKeyNotification_HasExpectedProperties() Assert.Equal(KeyNotificationType.Unknown, notification.Type); Assert.False(notification.IsType("del"u8)); Assert.True(notification.GetKey().IsNull); - Assert.True(notification.GetSubKey().IsNull); + Assert.True(notification.GetSubKeys().FirstOrDefault().IsNull); Assert.Equal(0, notification.GetKeyByteCount()); Assert.Equal(0, notification.GetKeyMaxByteCount()); Assert.Equal(0, notification.GetKeyCharCount()); @@ -841,38 +841,85 @@ public void KeyNotificationKeyStripping(bool asString) Assert.Equal("abc", clob.Slice(0, charsWritten).ToString()); } - [Fact] - public void SubKeySpace_HSet_ParsesCorrectly() + [Theory] + [InlineData("hset|6:field1", "field1", "Single subkey")] + [InlineData("hset|6:field1|6:field2", "field1", "Multiple subkeys - returns first only")] + [InlineData("hset|6:field1|6:field2|6:field3", "field1", "Three subkeys - returns first only")] + public void SubKeySpace_HSet_ParsesCorrectly(string payload, string expectedFirstSubKey, string description) { - // __subkeyspace@4__:mykey with payload hset|6:field1 + // __subkeyspace@4__:mykey with payload like hset|6:field1 or hset|6:field1|6:field2 var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); - RedisValue value = "hset|6:field1"; + RedisValue value = payload; - Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + Assert.True(KeyNotification.TryParse(channel, value, out var notification), description); Assert.Equal(KeyNotificationKind.SubKeySpace, notification.Kind); Assert.Equal(4, notification.Database); Assert.Equal(KeyNotificationType.HSet, notification.Type); Assert.True(notification.IsType("hset"u8)); Assert.Equal("mykey", (string?)notification.GetKey()); - Assert.Equal("field1", (string?)notification.GetSubKey()); + Assert.Equal(expectedFirstSubKey, (string?)notification.GetSubKeys().First()); } - [Fact] - public void SubKeyEvent_HSet_ParsesCorrectly() + [Theory] + [InlineData("hset|6:field1", new[] { "field1" })] + [InlineData("hset|6:field1|6:field2", new[] { "field1", "field2" })] + [InlineData("hset|6:field1|6:field2|6:field3", new[] { "field1", "field2", "field3" })] + [InlineData("hset|4:key1|5:key22|6:key333", new[] { "key1", "key22", "key333" })] + public void SubKeySpace_GetSubKeys(string payload, string[] expectedSubKeys) { - // __subkeyevent@4__:hset with payload 5:mykey|6:field1 - var channel = RedisChannel.Literal("__subkeyevent@4__:hset"); - RedisValue value = "5:mykey|6:field1"; + var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); + RedisValue value = payload; Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + var subKeys = new List(); + foreach (var subKey in notification.GetSubKeys()) + { + subKeys.Add((string?)subKey); + } + + Assert.Equal(expectedSubKeys, subKeys); + } + + [Theory] + [InlineData("5:mykey|6:field1", "field1", "Single subkey")] + [InlineData("5:mykey|6:field1|6:field2", "field1", "Multiple subkeys - returns first only")] + [InlineData("5:mykey|6:field1|6:field2|6:field3", "field1", "Three subkeys - returns first only")] + public void SubKeyEvent_HSet_ParsesCorrectly(string payload, string expectedFirstSubKey, string description) + { + // __subkeyevent@4__:hset with payload like 5:mykey|6:field1 or 5:mykey|6:field1|6:field2 + var channel = RedisChannel.Literal("__subkeyevent@4__:hset"); + RedisValue value = payload; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification), description); + Assert.Equal(KeyNotificationKind.SubKeyEvent, notification.Kind); Assert.Equal(4, notification.Database); Assert.Equal(KeyNotificationType.HSet, notification.Type); Assert.True(notification.IsType("hset"u8)); Assert.Equal("mykey", (string?)notification.GetKey()); - Assert.Equal("field1", (string?)notification.GetSubKey()); + Assert.Equal(expectedFirstSubKey, (string?)notification.GetSubKeys().First()); + } + + [Theory] + [InlineData("5:mykey|6:field1", new[] { "field1" })] + [InlineData("5:mykey|6:field1|6:field2", new[] { "field1", "field2" })] + [InlineData("5:mykey|6:field1|6:field2|6:field3", new[] { "field1", "field2", "field3" })] + public void SubKeyEvent_GetSubKeys(string payload, string[] expectedSubKeys) + { + var channel = RedisChannel.Literal("__subkeyevent@4__:hset"); + RedisValue value = payload; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + var subKeys = new List(); + foreach (var subKey in notification.GetSubKeys()) + { + subKeys.Add((string?)subKey); + } + + Assert.Equal(expectedSubKeys, subKeys); } [Fact] @@ -889,24 +936,237 @@ public void SubKeySpaceItem_HSet_ParsesCorrectly() Assert.Equal(KeyNotificationType.HSet, notification.Type); Assert.True(notification.IsType("hset"u8)); Assert.Equal("mykey", (string?)notification.GetKey()); - Assert.Equal("field1", (string?)notification.GetSubKey()); + Assert.Equal("field1", (string?)notification.GetSubKeys().First()); } - [Fact] - public void SubKeySpaceEvent_HSet_ParsesCorrectly() + [Theory] + [InlineData("6:field1", "field1", "Single subkey")] + [InlineData("6:field1|6:field2", "field1", "Multiple subkeys - returns first only")] + [InlineData("6:field1|6:field2|6:field3", "field1", "Three subkeys - returns first only")] + public void SubKeySpaceEvent_HSet_ParsesCorrectly(string payload, string expectedFirstSubKey, string description) { - // __subkeyspaceevent@4__:hset|mykey with payload 6:field1 + // __subkeyspaceevent@4__:hset|mykey with payload like 6:field1 or 6:field1|6:field2 var channel = RedisChannel.Literal("__subkeyspaceevent@4__:hset|mykey"); - RedisValue value = "6:field1"; + RedisValue value = payload; - Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + Assert.True(KeyNotification.TryParse(channel, value, out var notification), description); Assert.Equal(KeyNotificationKind.SubKeySpaceEvent, notification.Kind); Assert.Equal(4, notification.Database); Assert.Equal(KeyNotificationType.HSet, notification.Type); Assert.True(notification.IsType("hset"u8)); Assert.Equal("mykey", (string?)notification.GetKey()); - Assert.Equal("field1", (string?)notification.GetSubKey()); + Assert.Equal(expectedFirstSubKey, (string?)notification.GetSubKeys().First()); + } + + [Theory] + [InlineData("6:field1", new[] { "field1" })] + [InlineData("6:field1|6:field2", new[] { "field1", "field2" })] + [InlineData("6:field1|6:field2|6:field3", new[] { "field1", "field2", "field3" })] + public void SubKeySpaceEvent_GetSubKeys(string payload, string[] expectedSubKeys) + { + var channel = RedisChannel.Literal("__subkeyspaceevent@4__:hset|mykey"); + RedisValue value = payload; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + var subKeys = new List(); + foreach (var subKey in notification.GetSubKeys()) + { + subKeys.Add((string?)subKey); + } + + Assert.Equal(expectedSubKeys, subKeys); + } + + [Fact] + public void SubKeySpaceItem_GetSingleSubKey() + { + // __subkeyspaceitem@4__:mykey\nfield1 + var channel = RedisChannel.Literal("__subkeyspaceitem@4__:mykey\nfield1"); + RedisValue value = RedisValue.EmptyString; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + var subKeys = new List(); + foreach (var subKey in notification.GetSubKeys()) + { + subKeys.Add((string?)subKey); + } + + Assert.Single(subKeys); + Assert.Equal("field1", subKeys[0]); + } + + [Fact] + public void GetSubKeys_DefaultEnumerable_ReturnsEmpty() + { + // Test that default SubKeyEnumerable returns empty set + var enumerable = default(KeyNotification.SubKeyEnumerable); + + var subKeys = new List(); + foreach (var subKey in enumerable) + { + subKeys.Add((string?)subKey); + } + + Assert.Empty(subKeys); + } + + [Fact] + public void GetSubKeys_DefaultEnumerator_MoveNextReturnsFalse() + { + // Test that default SubKeyEnumerator's MoveNext returns false + var enumerator = default(KeyNotification.SubKeyEnumerator); + + Assert.False(enumerator.MoveNext()); + } + + [Fact] + public void GetSubKeys_NonSubKeyNotification_ReturnsEmpty() + { + // Regular keyspace notification (not sub-key) should return empty + var channel = RedisChannel.Literal("__keyspace@4__:mykey"); + RedisValue value = "set"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); + + var subKeys = new List(); + foreach (var subKey in notification.GetSubKeys()) + { + subKeys.Add((string?)subKey); + } + + Assert.Empty(subKeys); + } + + [Fact] + public void GetSubKeys_Count_ReturnsCorrectCount() + { + var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); + RedisValue value = "hset|6:field1|6:field2|6:field3"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + Assert.Equal(3, notification.GetSubKeys().Count()); + } + + [Fact] + public void GetSubKeys_First_ReturnsFirstElement() + { + var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); + RedisValue value = "hset|6:field1|6:field2|6:field3"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + Assert.Equal("field1", (string?)notification.GetSubKeys().First()); + } + + [Fact] + public void GetSubKeys_First_ThrowsOnEmpty() + { + var channel = RedisChannel.Literal("__keyspace@4__:mykey"); + RedisValue value = "set"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + try + { + notification.GetSubKeys().First(); + Assert.Fail("Expected InvalidOperationException"); + } + catch (InvalidOperationException) + { + // Expected + } + } + + [Fact] + public void GetSubKeys_FirstOrDefault_ReturnsFirstElement() + { + var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); + RedisValue value = "hset|6:field1|6:field2|6:field3"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + Assert.Equal("field1", (string?)notification.GetSubKeys().FirstOrDefault()); + } + + [Fact] + public void GetSubKeys_FirstOrDefault_ReturnsNullOnEmpty() + { + var channel = RedisChannel.Literal("__keyspace@4__:mykey"); + RedisValue value = "set"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + Assert.True(notification.GetSubKeys().FirstOrDefault().IsNull); + } + + [Fact] + public void GetSubKeys_CopyTo_CopiesAllElements() + { + var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); + RedisValue value = "hset|6:field1|6:field2|6:field3"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + var destination = new RedisValue[5]; + var count = notification.GetSubKeys().CopyTo(destination); + + Assert.Equal(3, count); + Assert.Equal("field1", (string?)destination[0]); + Assert.Equal("field2", (string?)destination[1]); + Assert.Equal("field3", (string?)destination[2]); + } + + [Fact] + public void GetSubKeys_CopyTo_TruncatesWhenTooSmall() + { + var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); + RedisValue value = "hset|6:field1|6:field2|6:field3"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + var destination = new RedisValue[2]; + var count = notification.GetSubKeys().CopyTo(destination); + + Assert.Equal(2, count); + Assert.Equal("field1", (string?)destination[0]); + Assert.Equal("field2", (string?)destination[1]); + } + + [Fact] + public void GetSubKeys_ToArray_ReturnsArray() + { + var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); + RedisValue value = "hset|6:field1|6:field2|6:field3"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + var array = notification.GetSubKeys().ToArray(); + + Assert.Equal(3, array.Length); + Assert.Equal("field1", (string?)array[0]); + Assert.Equal("field2", (string?)array[1]); + Assert.Equal("field3", (string?)array[2]); + } + + [Fact] + public void GetSubKeys_ToList_ReturnsList() + { + var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); + RedisValue value = "hset|6:field1|6:field2|6:field3"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + var list = notification.GetSubKeys().ToList(); + + Assert.Equal(3, list.Count); + Assert.Equal("field1", (string?)list[0]); + Assert.Equal("field2", (string?)list[1]); + Assert.Equal("field3", (string?)list[2]); } [Fact] @@ -940,7 +1200,7 @@ public void SubKeySpace_GetSubKey_ReturnsCorrectValue() Assert.True(KeyNotification.TryParse(channel, value, out var notification)); Assert.Equal(KeyNotificationKind.SubKeySpace, notification.Kind); - var subKey = notification.GetSubKey(); + var subKey = notification.GetSubKeys().First(); Assert.False(subKey.IsNull, $"SubKey should not be null. Value: {value}"); Assert.Equal("field1", (string?)subKey); } @@ -977,7 +1237,7 @@ public void SubKeySpace_HExpire_ParsesCorrectly() Assert.Equal(KeyNotificationType.HExpire, notification.Type); Assert.True(notification.IsType("hexpire"u8)); Assert.Equal("hash", (string?)notification.GetKey()); - Assert.Equal("field", (string?)notification.GetSubKey()); + Assert.Equal("field", (string?)notification.GetSubKeys().First()); } [Fact] @@ -989,7 +1249,7 @@ public void NonSubKeyNotifications_ReturnNullSubKey() Assert.True(KeyNotification.TryParse(channel, value, out var notification)); Assert.Equal(KeyNotificationKind.KeySpace, notification.Kind); - Assert.True(notification.GetSubKey().IsNull); + Assert.True(notification.GetSubKeys().FirstOrDefault().IsNull); // Regular keyevent notification channel = RedisChannel.Literal("__keyevent@4__:del"); @@ -997,7 +1257,7 @@ public void NonSubKeyNotifications_ReturnNullSubKey() Assert.True(KeyNotification.TryParse(channel, value, out notification)); Assert.Equal(KeyNotificationKind.KeyEvent, notification.Kind); - Assert.True(notification.GetSubKey().IsNull); + Assert.True(notification.GetSubKeys().FirstOrDefault().IsNull); } [Fact] @@ -1297,7 +1557,7 @@ public void SubKey_SubKeySpace_SubkeyNotAffectedByKeyPrefix() Assert.Equal("123", (string?)notification.GetKey()); // The subkey should be returned as-is with its own "email:" prefix intact - var subkey = notification.GetSubKey(); + var subkey = notification.GetSubKeys().First(); Assert.Equal("email:123456", (string?)subkey); Assert.Equal(12, subkey.GetByteCount()); } @@ -1316,7 +1576,7 @@ public void SubKey_SubKeyEvent_SubkeyNotAffectedByKeyPrefix() Assert.Equal("123", (string?)notification.GetKey()); // The subkey should be returned as-is with its own "email:" prefix intact - var subkey = notification.GetSubKey(); + var subkey = notification.GetSubKeys().First(); Assert.Equal("email:123456", (string?)subkey); Assert.Equal(12, subkey.GetByteCount()); } @@ -1335,7 +1595,7 @@ public void SubKey_SubKeySpaceItem_SubkeyNotAffectedByKeyPrefix() Assert.Equal("123", (string?)notification.GetKey()); // The subkey should be returned as-is with its own "email:" prefix intact - var subkey = notification.GetSubKey(); + var subkey = notification.GetSubKeys().First(); Assert.Equal("email:123456", (string?)subkey); } @@ -1353,7 +1613,7 @@ public void SubKey_SubKeySpaceEvent_SubkeyNotAffectedByKeyPrefix() Assert.Equal("123", (string?)notification.GetKey()); // The subkey should be returned as-is with its own "email:" prefix intact - var subkey = notification.GetSubKey(); + var subkey = notification.GetSubKeys().First(); Assert.Equal("email:123456", (string?)subkey); Assert.Equal(12, subkey.GetByteCount()); } From c4b6140c22d8777f6c959c256a1603728ec86a36 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 23 Apr 2026 14:43:15 +0100 Subject: [PATCH 05/14] avoid a fixed stackalloc --- src/StackExchange.Redis/KeyNotification.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/StackExchange.Redis/KeyNotification.cs b/src/StackExchange.Redis/KeyNotification.cs index ced4016e9..4df6b57e5 100644 --- a/src/StackExchange.Redis/KeyNotification.cs +++ b/src/StackExchange.Redis/KeyNotification.cs @@ -332,9 +332,16 @@ internal static RedisValue ExtractLengthPrefixedValue(in RedisValue value, int o } // Slower path for non-contiguous values - Span buffer = stackalloc byte[256]; + const int MAX_STACK = 256; + byte[]? lease = null; + var maxCount = value.GetMaxByteCount(); + Span buffer = maxCount <= MAX_STACK + ? stackalloc byte[MAX_STACK] + : (lease = ArrayPool.Shared.Rent(maxCount)); var bytesWritten = value.CopyTo(buffer); - return ExtractLengthPrefixedValue(buffer.Slice(offset, bytesWritten - offset)); + var result = ExtractLengthPrefixedValue(buffer.Slice(offset, bytesWritten - offset)); + if (lease is not null) ArrayPool.Shared.Return(lease); + return result; } internal static RedisValue ExtractLengthPrefixedValue(ReadOnlySpan span) From aeda056526924467dd67d44f9eac3524e9cd5041 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 23 Apr 2026 17:06:41 +0100 Subject: [PATCH 06/14] CI file --- tests/RedisConfigs/.docker/Redis/Dockerfile | 2 +- tests/RedisConfigs/docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/RedisConfigs/.docker/Redis/Dockerfile b/tests/RedisConfigs/.docker/Redis/Dockerfile index 334a1d7b9..08f960ad1 100644 --- a/tests/RedisConfigs/.docker/Redis/Dockerfile +++ b/tests/RedisConfigs/.docker/Redis/Dockerfile @@ -1,4 +1,4 @@ -ARG CLIENT_LIBS_TEST_IMAGE=redislabs/client-libs-test:8.8-m02 +ARG CLIENT_LIBS_TEST_IMAGE=redislabs/client-libs-test:unstable-2480570909-debian FROM ${CLIENT_LIBS_TEST_IMAGE} COPY --from=configs ./Basic /data/Basic/ diff --git a/tests/RedisConfigs/docker-compose.yml b/tests/RedisConfigs/docker-compose.yml index 59ff825d8..f89fec012 100644 --- a/tests/RedisConfigs/docker-compose.yml +++ b/tests/RedisConfigs/docker-compose.yml @@ -5,7 +5,7 @@ services: build: context: .docker/Redis args: - CLIENT_LIBS_TEST_IMAGE: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.8-m02} + CLIENT_LIBS_TEST_IMAGE: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:unstable-2480570909-debian} additional_contexts: configs: . platform: linux From 3303920f4a854387914b136a9693117320b7aa0f Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 24 Apr 2026 08:48:50 +0100 Subject: [PATCH 07/14] tyop --- tests/RedisConfigs/.docker/Redis/Dockerfile | 2 +- tests/RedisConfigs/docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/RedisConfigs/.docker/Redis/Dockerfile b/tests/RedisConfigs/.docker/Redis/Dockerfile index 08f960ad1..53757b428 100644 --- a/tests/RedisConfigs/.docker/Redis/Dockerfile +++ b/tests/RedisConfigs/.docker/Redis/Dockerfile @@ -1,4 +1,4 @@ -ARG CLIENT_LIBS_TEST_IMAGE=redislabs/client-libs-test:unstable-2480570909-debian +ARG CLIENT_LIBS_TEST_IMAGE=redislabs/client-libs-test:unstable-24805570909-debian FROM ${CLIENT_LIBS_TEST_IMAGE} COPY --from=configs ./Basic /data/Basic/ diff --git a/tests/RedisConfigs/docker-compose.yml b/tests/RedisConfigs/docker-compose.yml index f89fec012..bdcb8625b 100644 --- a/tests/RedisConfigs/docker-compose.yml +++ b/tests/RedisConfigs/docker-compose.yml @@ -5,7 +5,7 @@ services: build: context: .docker/Redis args: - CLIENT_LIBS_TEST_IMAGE: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:unstable-2480570909-debian} + CLIENT_LIBS_TEST_IMAGE: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:unstable-24805570909-debian} additional_contexts: configs: . platform: linux From 5f26a0526455dd48ab9ba0bca9f16b92e691e22c Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 24 Apr 2026 11:12:13 +0100 Subject: [PATCH 08/14] integration tests for subkey notifications (something channel-routing related still failing) --- .../KeyNotification.SubKeys.cs | 4 +- tests/RedisConfigs/Basic/primary-6379.conf | 2 +- tests/RedisConfigs/Basic/replica-6380.conf | 2 +- tests/RedisConfigs/Basic/secure-6381.conf | 2 +- .../RedisConfigs/Basic/tls-ciphers-6384.conf | 2 +- tests/RedisConfigs/Cluster/cluster-7000.conf | 2 +- tests/RedisConfigs/Cluster/cluster-7001.conf | 2 +- tests/RedisConfigs/Cluster/cluster-7002.conf | 2 +- tests/RedisConfigs/Cluster/cluster-7003.conf | 2 +- tests/RedisConfigs/Cluster/cluster-7004.conf | 2 +- tests/RedisConfigs/Cluster/cluster-7005.conf | 2 +- tests/RedisConfigs/Failover/primary-6382.conf | 2 +- tests/RedisConfigs/Failover/replica-6383.conf | 2 +- tests/RedisConfigs/Sentinel/redis-7010.conf | 2 +- tests/RedisConfigs/Sentinel/redis-7011.conf | 2 +- .../KeyNotificationTests.cs | 101 +++++ .../PubSubKeyNotificationTests.cs | 386 +++++++++++++++++- 17 files changed, 485 insertions(+), 34 deletions(-) diff --git a/src/StackExchange.Redis/KeyNotification.SubKeys.cs b/src/StackExchange.Redis/KeyNotification.SubKeys.cs index 3f099860a..2e9d5135b 100644 --- a/src/StackExchange.Redis/KeyNotification.SubKeys.cs +++ b/src/StackExchange.Redis/KeyNotification.SubKeys.cs @@ -320,8 +320,8 @@ internal bool TryMoveNext(bool setCurrent) var valueLength = (int)value.Length(); _position += colonIndex + 1 + valueLength; - // Skip the | separator if present - if (_position < _data.Length && _data[_position] == (byte)'|') + // Skip the separator if present (| or ,) + if (_position < _data.Length && (_data[_position] == (byte)'|' || _data[_position] == (byte)',')) { _position++; } diff --git a/tests/RedisConfigs/Basic/primary-6379.conf b/tests/RedisConfigs/Basic/primary-6379.conf index 2da592601..0128e5e22 100644 --- a/tests/RedisConfigs/Basic/primary-6379.conf +++ b/tests/RedisConfigs/Basic/primary-6379.conf @@ -8,4 +8,4 @@ appendonly no dbfilename "primary-6379.rdb" save "" enable-debug-command yes -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Basic/replica-6380.conf b/tests/RedisConfigs/Basic/replica-6380.conf index 0c1650513..5cd671f6d 100644 --- a/tests/RedisConfigs/Basic/replica-6380.conf +++ b/tests/RedisConfigs/Basic/replica-6380.conf @@ -8,4 +8,4 @@ appendonly no dir "../Temp" dbfilename "replica-6380.rdb" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Basic/secure-6381.conf b/tests/RedisConfigs/Basic/secure-6381.conf index ad2e380ad..5037accd8 100644 --- a/tests/RedisConfigs/Basic/secure-6381.conf +++ b/tests/RedisConfigs/Basic/secure-6381.conf @@ -5,4 +5,4 @@ maxmemory 512mb dir "../Temp" dbfilename "secure-6381.rdb" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ 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 857d5c741..374f272b3 100644 --- a/tests/RedisConfigs/Basic/tls-ciphers-6384.conf +++ b/tests/RedisConfigs/Basic/tls-ciphers-6384.conf @@ -9,4 +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 +notify-keyspace-events AKESTIV diff --git a/tests/RedisConfigs/Cluster/cluster-7000.conf b/tests/RedisConfigs/Cluster/cluster-7000.conf index ad11a23fd..033dcba9d 100644 --- a/tests/RedisConfigs/Cluster/cluster-7000.conf +++ b/tests/RedisConfigs/Cluster/cluster-7000.conf @@ -7,4 +7,4 @@ appendonly yes dbfilename "dump-7000.rdb" appendfilename "appendonly-7000.aof" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Cluster/cluster-7001.conf b/tests/RedisConfigs/Cluster/cluster-7001.conf index 589f9ea23..29e3f3de2 100644 --- a/tests/RedisConfigs/Cluster/cluster-7001.conf +++ b/tests/RedisConfigs/Cluster/cluster-7001.conf @@ -7,4 +7,4 @@ appendonly yes dbfilename "dump-7001.rdb" appendfilename "appendonly-7001.aof" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Cluster/cluster-7002.conf b/tests/RedisConfigs/Cluster/cluster-7002.conf index 66a376865..1e4320095 100644 --- a/tests/RedisConfigs/Cluster/cluster-7002.conf +++ b/tests/RedisConfigs/Cluster/cluster-7002.conf @@ -7,4 +7,4 @@ appendonly yes dbfilename "dump-7002.rdb" appendfilename "appendonly-7002.aof" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Cluster/cluster-7003.conf b/tests/RedisConfigs/Cluster/cluster-7003.conf index 1f4883023..dca308929 100644 --- a/tests/RedisConfigs/Cluster/cluster-7003.conf +++ b/tests/RedisConfigs/Cluster/cluster-7003.conf @@ -7,4 +7,4 @@ appendonly yes dbfilename "dump-7003.rdb" appendfilename "appendonly-7003.aof" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Cluster/cluster-7004.conf b/tests/RedisConfigs/Cluster/cluster-7004.conf index 93d75f38a..290a37464 100644 --- a/tests/RedisConfigs/Cluster/cluster-7004.conf +++ b/tests/RedisConfigs/Cluster/cluster-7004.conf @@ -7,4 +7,4 @@ appendonly yes dbfilename "dump-7004.rdb" appendfilename "appendonly-7004.aof" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Cluster/cluster-7005.conf b/tests/RedisConfigs/Cluster/cluster-7005.conf index c9b5d55e2..0f936ee4e 100644 --- a/tests/RedisConfigs/Cluster/cluster-7005.conf +++ b/tests/RedisConfigs/Cluster/cluster-7005.conf @@ -7,4 +7,4 @@ appendonly yes dbfilename "dump-7005.rdb" appendfilename "appendonly-7005.aof" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Failover/primary-6382.conf b/tests/RedisConfigs/Failover/primary-6382.conf index 6055c0347..de55eea89 100644 --- a/tests/RedisConfigs/Failover/primary-6382.conf +++ b/tests/RedisConfigs/Failover/primary-6382.conf @@ -7,4 +7,4 @@ dir "../Temp" appendonly no dbfilename "primary-6382.rdb" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Failover/replica-6383.conf b/tests/RedisConfigs/Failover/replica-6383.conf index e07f5a69d..8e3987de2 100644 --- a/tests/RedisConfigs/Failover/replica-6383.conf +++ b/tests/RedisConfigs/Failover/replica-6383.conf @@ -8,4 +8,4 @@ appendonly no dir "../Temp" dbfilename "replica-6383.rdb" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Sentinel/redis-7010.conf b/tests/RedisConfigs/Sentinel/redis-7010.conf index 878160632..566492e55 100644 --- a/tests/RedisConfigs/Sentinel/redis-7010.conf +++ b/tests/RedisConfigs/Sentinel/redis-7010.conf @@ -6,4 +6,4 @@ appendonly no dir "../Temp" dbfilename "sentinel-target-7010.rdb" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/RedisConfigs/Sentinel/redis-7011.conf b/tests/RedisConfigs/Sentinel/redis-7011.conf index 08b8dad1a..45598a933 100644 --- a/tests/RedisConfigs/Sentinel/redis-7011.conf +++ b/tests/RedisConfigs/Sentinel/redis-7011.conf @@ -7,4 +7,4 @@ appendonly no dir "../Temp" dbfilename "sentinel-target-7011.rdb" save "" -notify-keyspace-events AKE \ No newline at end of file +notify-keyspace-events AKESTIV \ No newline at end of file diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs index 876bf64aa..b4f585a29 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs @@ -1617,4 +1617,105 @@ public void SubKey_SubKeySpaceEvent_SubkeyNotAffectedByKeyPrefix() Assert.Equal("email:123456", (string?)subkey); Assert.Equal(12, subkey.GetByteCount()); } + + [Fact] + public void SubKeyEvent_MultipleFields_ParsesCorrectly() + { + // __subkeyevent@0__:hset with payload "1:k|6:field1,6:field2,6:field3" + // This represents an HSET on key "k" with fields "field1", "field2", "field3" + // Note: fields are separated by commas, not pipes (as per Redis 8.8 actual behavior) + var channel = RedisChannel.Literal("__subkeyevent@0__:hset"); + RedisValue value = "1:k|6:field1,6:field2,6:field3"; + + log.WriteLine($"Testing channel: '{channel}', value: '{value}'"); + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.SubKeyEvent, notification.Kind); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.HSet, notification.Type); + Assert.Equal("k", (string?)notification.GetKey()); + + // Test sub-keys + var subKeys = notification.GetSubKeys(); + int count = subKeys.Count(); + log.WriteLine($"Sub-key count: {count}"); + + Assert.Equal(3, count); + + var fieldsList = subKeys.ToList(); + Assert.Equal(3, fieldsList.Count); + Assert.Equal("field1", (string?)fieldsList[0]); + Assert.Equal("field2", (string?)fieldsList[1]); + Assert.Equal("field3", (string?)fieldsList[2]); + } + + [Fact] + public void SubKeyEvent_RealWorldPayload_ParsesCorrectly() + { + // Real payload observed from Redis 8.8 server + var channel = RedisChannel.Literal("__subkeyevent@0__:hset"); + RedisValue value = "41:d7213ec1-e834-4fb7-9a4d-a0d8d6bfbc7e/hash|6:field1,6:field2,6:field3"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.SubKeyEvent, notification.Kind); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.HSet, notification.Type); + Assert.Equal("d7213ec1-e834-4fb7-9a4d-a0d8d6bfbc7e/hash", (string?)notification.GetKey()); + + // Test sub-keys + var subKeys = notification.GetSubKeys(); + Assert.Equal(3, subKeys.Count()); + + var fieldsList = subKeys.ToArray(); + Assert.Equal("field1", (string?)fieldsList[0]); + Assert.Equal("field2", (string?)fieldsList[1]); + Assert.Equal("field3", (string?)fieldsList[2]); + } + + [Fact] + public void SubKeySpace_MultipleFields_ParsesCorrectly() + { + // __subkeyspace@0__:mykey with payload "4:hset|6:field1,6:field2" + var channel = RedisChannel.Literal("__subkeyspace@0__:mykey"); + RedisValue value = "4:hset|6:field1,6:field2"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.SubKeySpace, notification.Kind); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.HSet, notification.Type); + Assert.Equal("mykey", (string?)notification.GetKey()); + + var subKeys = notification.GetSubKeys(); + Assert.Equal(2, subKeys.Count()); + + var fieldsList = subKeys.ToArray(); + Assert.Equal("field1", (string?)fieldsList[0]); + Assert.Equal("field2", (string?)fieldsList[1]); + } + + [Fact] + public void SubKeySpaceEvent_MultipleFields_ParsesCorrectly() + { + // __subkeyspaceevent@0__:hset|mykey with payload "6:field1,6:field2,6:field3" + var channel = RedisChannel.Literal("__subkeyspaceevent@0__:hset|mykey"); + RedisValue value = "6:field1,6:field2,6:field3"; + + Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); + + Assert.Equal(KeyNotificationKind.SubKeySpaceEvent, notification.Kind); + Assert.Equal(0, notification.Database); + Assert.Equal(KeyNotificationType.HSet, notification.Type); + Assert.Equal("mykey", (string?)notification.GetKey()); + + var subKeys = notification.GetSubKeys(); + Assert.Equal(3, subKeys.Count()); + + var fieldsList = subKeys.ToArray(); + Assert.Equal("field1", (string?)fieldsList[0]); + Assert.Equal("field2", (string?)fieldsList[1]); + Assert.Equal("field3", (string?)fieldsList[2]); + } } diff --git a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs index 0fe90cb89..9f69cf978 100644 --- a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs @@ -56,26 +56,43 @@ private IInternalConnectionMultiplexer Create(bool withChannelPrefix) => private static Random SharedRandom { get; } = new(); #endif - [Fact] - public async Task KeySpace_Events_Enabled() + /// + /// Creates a connection for notification tests and asserts that the required notification tokens are available. + /// Uses Assert.SkipUnless to skip the test if the configuration is not available. + /// + /// The kind of notification to check support for. + /// Whether to use a channel prefix. + /// A connection multiplexer configured for the specified notification kind. + private async Task ConnectAsync(KeyNotificationKind kind, bool withChannelPrefix = false) { - // 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 conn = Create(channelPrefix: withChannelPrefix ? "prefix:" : null, allowAdmin: true); + var muxer = conn; + + var requiredTokens = kind switch + { + KeyNotificationKind.KeySpace => "AK", // A = all events, K = keyspace + KeyNotificationKind.KeyEvent => "AE", // A = all events, E = keyevent + KeyNotificationKind.SubKeySpace => "AS", // A = all events, S = sub-keyspace + KeyNotificationKind.SubKeyEvent => "AT", // A = all events, T = sub-keyevent + KeyNotificationKind.SubKeySpaceItem => "AI", // A = all events, I = sub-keyspace-item + KeyNotificationKind.SubKeySpaceEvent => "AV", // A = all events, V = sub-keyspace-event + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unsupported KeyNotificationKind"), + }; + + foreach (var ep in muxer.GetEndPoints()) { - var server = conn.GetServer(ep); + var server = muxer.GetServer(ep); var config = (await server.ConfigGetAsync("notify-keyspace-events")).Single(); - Log($"[{Format.ToString(ep)}] notify-keyspace-events: '{config.Value}'"); + var value = config.Value.ToString() ?? ""; - // this is a very broad config, but it's what we use in CI (and probably a common basic config) - if (config.Value != "AKE") + // Check that the config contains all required tokens + foreach (var token in requiredTokens) { - failures++; + Assert.SkipUnless(value.Contains(token), $"Server {ep} notify-keyspace-events config '{value}' missing required token '{token}' for {kind}"); } } - // for details, check the log output - Assert.Equal(0, failures); + + return conn; } [Fact] @@ -119,7 +136,7 @@ private sealed class Counter [InlineData(false)] public async Task KeyEvent_CanObserveSimple_ViaCallbackHandler(bool withChannelPrefix) { - await using var conn = Create(withChannelPrefix); + await using var conn = await ConnectAsync(KeyNotificationKind.KeyEvent, withChannelPrefix); var db = conn.GetDatabase(); var keys = InventKeys(out var prefix); @@ -159,7 +176,7 @@ await sub.SubscribeAsync(channel, (recvChannel, recvValue) => [InlineData(false)] public async Task KeyEvent_CanObserveSimple_ViaQueue(bool withChannelPrefix) { - await using var conn = Create(withChannelPrefix); + await using var conn = await ConnectAsync(KeyNotificationKind.KeyEvent, withChannelPrefix); var db = conn.GetDatabase(); var keys = InventKeys(out var prefix); @@ -203,7 +220,7 @@ public async Task KeyEvent_CanObserveSimple_ViaQueue(bool withChannelPrefix) [InlineData(false)] public async Task KeyNotification_CanObserveSimple_ViaCallbackHandler(bool withChannelPrefix) { - await using var conn = Create(withChannelPrefix); + await using var conn = await ConnectAsync(KeyNotificationKind.KeySpace, withChannelPrefix); var db = conn.GetDatabase(); var keys = InventKeys(out var prefix); @@ -247,7 +264,7 @@ public async Task KeyNotification_CanObserveSimple_ViaCallbackHandler(bool withC [InlineData(false)] public async Task KeyNotification_CanObserveSimple_ViaQueue(bool withChannelPrefix) { - await using var conn = Create(withChannelPrefix); + await using var conn = await ConnectAsync(KeyNotificationKind.KeySpace, withChannelPrefix); var db = conn.GetDatabase(); var keys = InventKeys(out var prefix); @@ -289,7 +306,7 @@ await sub.SubscribeAsync(channel, (recvChannel, recvValue) => [InlineData(false, true)] public async Task KeyNotification_CanObserveSingleKey_ViaQueue(bool withChannelPrefix, bool withKeyPrefix) { - await using var conn = Create(withChannelPrefix); + await using var conn = await ConnectAsync(KeyNotificationKind.KeySpace, withChannelPrefix); string keyPrefix = withKeyPrefix ? "isolated:" : ""; byte[] keyPrefixBytes = Encoding.UTF8.GetBytes(keyPrefix); var db = conn.GetDatabase().WithKeyPrefix(keyPrefix); @@ -426,4 +443,337 @@ private async Task SendAndObserveAsync( return counter; } #endif + + // ========== Sub-Key (Hash Field) Notification Tests ========== + + /// + /// Helper to send hash operations and observe field-level notifications. + /// + private async Task SendHashOperationsAndObserveAsync( + RedisKey hashKey, + string[] fields, + IDatabase db, + TaskCompletionSource allDone, + Counter callbackCount, + ConcurrentDictionary observedFieldCounts, + int operationCount = DefaultEventCount) + { + await Task.Delay(300).ForAwait(); // give it a moment to settle + + Dictionary sentCounts = new(fields.Length); + foreach (var field in fields) + { + sentCounts[field] = new(); + } + + for (int i = 0; i < operationCount; i++) + { + var field = fields[SharedRandom.Next(0, fields.Length)]; + sentCounts[field].Increment(); + await db.HashSetAsync(hashKey, field, i); + } + + // Wait for all events to be observed + try + { + Assert.True(await allDone.Task.WithTimeout(5000)); + } + catch (TimeoutException) when (callbackCount.Count == 0) + { + Assert.Fail($"Timeout with zero events; are sub-keyspace events enabled?"); + } + + foreach (var field in fields) + { + Assert.Equal(sentCounts[field].Count, observedFieldCounts[field].Count); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SubKeyEvent_CanObserveHashFields_ViaCallback(bool withChannelPrefix) + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeyEvent, withChannelPrefix); + var db = conn.GetDatabase(); + + var hashKey = $"{Guid.NewGuid()}/hash"; + var fields = new[] { "field1", "field2", "field3", "field4", "field5" }; + var channel = RedisChannel.SubKeyEvent(KeyNotificationType.HSet, db.Database); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsPattern); + Log($"Monitoring channel: {channel}"); + + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + Counter callbackCount = new(), matchingEventCount = new(); + TaskCompletionSource allDone = new(); + + ConcurrentDictionary observedFieldCounts = new(); + foreach (var field in fields) + { + observedFieldCounts[field] = new(); + } + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + callbackCount.Increment(); + Log($"SubKeyEvent: Received on '{recvChannel}', value: '{recvValue}'"); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeyEvent + && notification.Type == KeyNotificationType.HSet + && notification.GetKey() == hashKey) + { + var subKeys = notification.GetSubKeys(); + foreach (var subKey in subKeys) + { + var fieldName = subKey.ToString(); + if (observedFieldCounts.TryGetValue(fieldName, out var counter)) + { + int currentCount = matchingEventCount.Increment(); + counter.Increment(); + Log($"Observed field: '{fieldName}' after {currentCount} events"); + + if (currentCount == DefaultEventCount) + { + allDone.TrySetResult(true); + } + } + } + } + }); + + await SendHashOperationsAndObserveAsync(hashKey, fields, db, allDone, callbackCount, observedFieldCounts); + await sub.UnsubscribeAsync(channel); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SubKeySpace_CanObserveHashFields_ViaPrefix(bool withChannelPrefix) + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeySpace, withChannelPrefix); + var db = conn.GetDatabase(); + + var prefix = $"{Guid.NewGuid()}/"; + var hashKey = $"{prefix}myhash"; + var fields = new[] { "field1", "field2", "field3" }; + var channel = RedisChannel.SubKeySpacePrefix(prefix, db.Database); + Assert.True(channel.IsMultiNode); + Assert.True(channel.IsPattern); + Log($"Monitoring channel: {channel}"); + + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + Counter callbackCount = new(), matchingEventCount = new(); + TaskCompletionSource allDone = new(); + + ConcurrentDictionary observedFieldCounts = new(); + foreach (var field in fields) + { + observedFieldCounts[field] = new(); + } + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + callbackCount.Increment(); + Log($"SubKeySpace: Received on '{recvChannel}', value: '{recvValue}'"); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeySpace + && notification.Type == KeyNotificationType.HSet) + { + var subKeys = notification.GetSubKeys(); + foreach (var subKey in subKeys) + { + var fieldName = subKey.ToString(); + if (observedFieldCounts.TryGetValue(fieldName, out var counter)) + { + int currentCount = matchingEventCount.Increment(); + counter.Increment(); + Log($"Observed field: '{fieldName}' in key '{notification.GetKey()}' after {currentCount} events"); + + if (currentCount == DefaultEventCount) + { + allDone.TrySetResult(true); + } + } + } + } + }); + + await SendHashOperationsAndObserveAsync(hashKey, fields, db, allDone, callbackCount, observedFieldCounts); + await sub.UnsubscribeAsync(channel); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SubKeySpaceItem_CanObserveSpecificHashField(bool withChannelPrefix) + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeySpaceItem, withChannelPrefix); + var db = conn.GetDatabase(); + + var hashKey = $"{Guid.NewGuid()}/hash"; + var targetField = "field2"; + var fields = new[] { "field1", targetField, "field3" }; + var channel = RedisChannel.SubKeySpaceItem(hashKey, targetField, db.Database); + Assert.False(channel.IsMultiNode); // Single key subscription can route to specific node + Assert.False(channel.IsPattern); + Log($"Monitoring channel: {channel}"); + + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + Counter callbackCount = new(), targetFieldCount = new(); + TaskCompletionSource allDone = new(); + + int expectedCount = (DefaultEventCount / fields.Length) + 2; // Approximate expected hits for target field + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + callbackCount.Increment(); + Log($"SubKeySpaceItem: Received on '{recvChannel}', value: '{recvValue}'"); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeySpaceItem + && notification.Type == KeyNotificationType.HSet) + { + var subKey = notification.GetSubKeys().FirstOrDefault(); + var fieldName = subKey.ToString(); + Assert.Equal(targetField, fieldName); // Should only observe the specific field + targetFieldCount.Increment(); + Log($"Observed target field: '{fieldName}' ({targetFieldCount.Count} times)"); + + if (targetFieldCount.Count >= expectedCount) + { + allDone.TrySetResult(true); + } + } + }); + + await Task.Delay(300).ForAwait(); // give it a moment to settle + + // Set various fields, but only targetField should trigger notifications + for (int i = 0; i < DefaultEventCount; i++) + { + var field = fields[SharedRandom.Next(0, fields.Length)]; + await db.HashSetAsync(hashKey, field, i); + } + + Assert.True(await allDone.Task.WithTimeout(5000)); + Assert.True(targetFieldCount.Count >= expectedCount / 2); // At least some hits on target field + + await sub.UnsubscribeAsync(channel); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SubKeySpaceEvent_CanObserveHashFields_SingleKey_SpecificEvent(bool withChannelPrefix) + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeySpaceEvent, withChannelPrefix); + var db = conn.GetDatabase(); + + var hashKey = $"{Guid.NewGuid()}/hash"; + var fields = new[] { "field1", "field2", "field3" }; + var channel = RedisChannel.SubKeySpaceEvent(KeyNotificationType.HSet, hashKey, db.Database); + Assert.True(channel.IsMultiNode); + Assert.False(channel.IsPattern); + Log($"Monitoring channel: {channel}"); + + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + Counter callbackCount = new(), matchingEventCount = new(); + TaskCompletionSource allDone = new(); + + ConcurrentDictionary observedFieldCounts = new(); + foreach (var field in fields) + { + observedFieldCounts[field] = new(); + } + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + callbackCount.Increment(); + Log($"SubKeySpaceEvent: Received on '{recvChannel}', value: '{recvValue}'"); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeySpaceEvent + && notification.Type == KeyNotificationType.HSet) + { + var subKeys = notification.GetSubKeys(); + foreach (var subKey in subKeys) + { + var fieldName = subKey.ToString(); + if (observedFieldCounts.TryGetValue(fieldName, out var counter)) + { + int currentCount = matchingEventCount.Increment(); + counter.Increment(); + Log($"Observed field: '{fieldName}' after {currentCount} events"); + + if (currentCount == DefaultEventCount) + { + allDone.TrySetResult(true); + } + } + } + } + }); + + await SendHashOperationsAndObserveAsync(hashKey, fields, db, allDone, callbackCount, observedFieldCounts); + await sub.UnsubscribeAsync(channel); + } + + [Fact] + public async Task SubKeyEvent_MultipleFields_SingleOperation() + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeyEvent); + var db = conn.GetDatabase(); + + var hashKey = $"{Guid.NewGuid()}/hash"; + var channel = RedisChannel.SubKeyEvent(KeyNotificationType.HSet, db.Database); + Log($"Monitoring channel: {channel}"); + + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + Counter callbackCount = new(); + TaskCompletionSource allDone = new(); + + HashSet observedFields = new(); + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + callbackCount.Increment(); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeyEvent + && notification.Type == KeyNotificationType.HSet + && notification.GetKey() == hashKey) + { + var subKeys = notification.GetSubKeys(); + foreach (var subKey in subKeys) + { + observedFields.Add(subKey.ToString()); + } + + if (observedFields.Count >= 3) + { + allDone.TrySetResult(true); + } + } + }); + + await Task.Delay(300).ForAwait(); // give it a moment to settle + + // Set multiple fields in a single operation + await db.HashSetAsync(hashKey, new HashEntry[] + { + new("field1", "value1"), + new("field2", "value2"), + new("field3", "value3"), + }); + + Assert.True(await allDone.Task.WithTimeout(5000)); + Assert.Contains("field1", observedFields); + Assert.Contains("field2", observedFields); + Assert.Contains("field3", observedFields); + + await sub.UnsubscribeAsync(channel); + } } From 32987f5715d0f1de527e7c9db5d2be05223fbf19 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 24 Apr 2026 11:42:25 +0100 Subject: [PATCH 09/14] fix channel-prefix scenarios --- src/StackExchange.Redis/RawResult.cs | 4 +++- .../StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/StackExchange.Redis/RawResult.cs b/src/StackExchange.Redis/RawResult.cs index 2b2b3989a..300535ad4 100644 --- a/src/StackExchange.Redis/RawResult.cs +++ b/src/StackExchange.Redis/RawResult.cs @@ -209,7 +209,9 @@ internal RedisChannel AsRedisChannel(byte[]? channelPrefix, RedisChannel.RedisCh // 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)) + if (StartsWith("__"u8) && (StartsWith("__keyspace@"u8) || StartsWith("__keyevent@"u8) || + StartsWith("__subkeyspace@"u8) || StartsWith("__subkeyevent@"u8) || + StartsWith("__subkeyspaceitem@"u8) || StartsWith("__subkeyspaceevent@"u8))) { // use as-is return new RedisChannel(GetBlob(), options); diff --git a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs index 9f69cf978..ee5229a99 100644 --- a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs @@ -502,6 +502,7 @@ public async Task SubKeyEvent_CanObserveHashFields_ViaCallback(bool withChannelP var channel = RedisChannel.SubKeyEvent(KeyNotificationType.HSet, db.Database); Assert.True(channel.IsMultiNode); Assert.False(channel.IsPattern); + Assert.True(channel.IgnoreChannelPrefix); // Keyspace notifications should ignore channel prefix Log($"Monitoring channel: {channel}"); var sub = conn.GetSubscriber(); @@ -514,7 +515,8 @@ public async Task SubKeyEvent_CanObserveHashFields_ViaCallback(bool withChannelP { observedFieldCounts[field] = new(); } - + // withChannelPrefix: true, "SUBSCRIBE" "__subkeyevent@0__:hset" + // withChannelPrefix: false, "SUBSCRIBE" "__subkeyevent@0__:hset" await sub.SubscribeAsync(channel, (recvChannel, recvValue) => { callbackCount.Increment(); @@ -561,6 +563,7 @@ public async Task SubKeySpace_CanObserveHashFields_ViaPrefix(bool withChannelPre var channel = RedisChannel.SubKeySpacePrefix(prefix, db.Database); Assert.True(channel.IsMultiNode); Assert.True(channel.IsPattern); + Assert.True(channel.IgnoreChannelPrefix); // Keyspace notifications should ignore channel prefix Log($"Monitoring channel: {channel}"); var sub = conn.GetSubscriber(); From fccaaaa8e0dce4a655c8c55d1fd670eb98eb773c Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 24 Apr 2026 11:51:47 +0100 Subject: [PATCH 10/14] fix unit test (bad payload) --- src/StackExchange.Redis/KeyNotification.SubKeys.cs | 6 +++--- src/StackExchange.Redis/KeyNotification.cs | 2 +- ...KeyNotificationTests.cs => KeyNotificationUnitTests.cs} | 7 ++++--- 3 files changed, 8 insertions(+), 7 deletions(-) rename tests/StackExchange.Redis.Tests/{KeyNotificationTests.cs => KeyNotificationUnitTests.cs} (99%) diff --git a/src/StackExchange.Redis/KeyNotification.SubKeys.cs b/src/StackExchange.Redis/KeyNotification.SubKeys.cs index 2e9d5135b..eef3684c5 100644 --- a/src/StackExchange.Redis/KeyNotification.SubKeys.cs +++ b/src/StackExchange.Redis/KeyNotification.SubKeys.cs @@ -204,8 +204,8 @@ internal SubKeyEnumerator(scoped KeyNotification notification) { case KeyNotificationKind.SubKeySpace: case KeyNotificationKind.SubKeyEvent: - // Payload: |:[|:...] or :|:[|:...] - // We need to skip to the first | and then iterate through length-prefixed subkeys + // Payload: |:,:,... or :|:,:,... + // We need to skip to the first | and then iterate through comma-separated length-prefixed subkeys _data = CopyAndLeaseValue(notification._value, out _lease); // Find the first pipe to skip the event/key part @@ -242,7 +242,7 @@ internal SubKeyEnumerator(scoped KeyNotification notification) break; case KeyNotificationKind.SubKeySpaceEvent: - // Payload: :[|:...] + // Payload: :,:,... _data = CopyAndLeaseValue(notification._value, out _lease); _position = 0; break; diff --git a/src/StackExchange.Redis/KeyNotification.cs b/src/StackExchange.Redis/KeyNotification.cs index 4df6b57e5..899775090 100644 --- a/src/StackExchange.Redis/KeyNotification.cs +++ b/src/StackExchange.Redis/KeyNotification.cs @@ -811,7 +811,7 @@ public KeyNotificationType Type return KeyNotificationTypeMetadata.Parse(ChannelSuffix); case KeyNotificationKind.SubKeySpace: - // Payload contains |:[|:...] + // Payload contains |:,:,... if (_value.TryGetSpan(out var directSub)) { var pipeIndexSub = directSub.IndexOf((byte)'|'); diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs similarity index 99% rename from tests/StackExchange.Redis.Tests/KeyNotificationTests.cs rename to tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs index b4f585a29..8c4b27523 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs @@ -7,7 +7,7 @@ namespace StackExchange.Redis.Tests; -public class KeyNotificationTests(ITestOutputHelper log) +public class KeyNotificationUnitTests(ITestOutputHelper log) { [Theory] [InlineData("foo", "foo")] @@ -1677,9 +1677,10 @@ public void SubKeyEvent_RealWorldPayload_ParsesCorrectly() [Fact] public void SubKeySpace_MultipleFields_ParsesCorrectly() { - // __subkeyspace@0__:mykey with payload "4:hset|6:field1,6:field2" + // __subkeyspace@0__:mykey with payload "hset|6:field1,6:field2" + // Format: |:,:... var channel = RedisChannel.Literal("__subkeyspace@0__:mykey"); - RedisValue value = "4:hset|6:field1,6:field2"; + RedisValue value = "hset|6:field1,6:field2"; Assert.True(KeyNotification.TryParse(in channel, in value, out var notification)); From d6d013512976a28108804123033df4e8aac472cc Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 24 Apr 2026 12:01:30 +0100 Subject: [PATCH 11/14] fix default-span "fixed" scenario; we could pre-check the length, but let's just fix the underlying problem --- src/StackExchange.Redis/FrameworkShims.cs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/StackExchange.Redis/FrameworkShims.cs b/src/StackExchange.Redis/FrameworkShims.cs index ce954406d..8907d33dc 100644 --- a/src/StackExchange.Redis/FrameworkShims.cs +++ b/src/StackExchange.Redis/FrameworkShims.cs @@ -6,6 +6,8 @@ #else // To support { get; init; } properties using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Text; namespace System.Runtime.CompilerServices @@ -35,9 +37,9 @@ internal static class EncodingExtensions { public static unsafe int GetBytes(this Encoding encoding, ReadOnlySpan source, Span destination) { - fixed (byte* bPtr = destination) + fixed (byte* bPtr = &MemoryMarshal.GetReference(destination)) { - fixed (char* cPtr = source) + fixed (char* cPtr = &MemoryMarshal.GetReference(source)) { return encoding.GetBytes(cPtr, source.Length, bPtr, destination.Length); } @@ -46,9 +48,9 @@ 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 (byte* bPtr = &MemoryMarshal.GetReference(source)) { - fixed (char* cPtr = destination) + fixed (char* cPtr = &MemoryMarshal.GetReference(destination)) { return encoding.GetChars(bPtr, source.Length, cPtr, destination.Length); } @@ -57,7 +59,7 @@ public static unsafe int GetChars(this Encoding encoding, ReadOnlySpan sou public static unsafe int GetCharCount(this Encoding encoding, ReadOnlySpan source) { - fixed (byte* bPtr = source) + fixed (byte* bPtr = &MemoryMarshal.GetReference(source)) { return encoding.GetCharCount(bPtr, source.Length); } @@ -65,7 +67,7 @@ public static unsafe int GetCharCount(this Encoding encoding, ReadOnlySpan public static unsafe string GetString(this Encoding encoding, ReadOnlySpan source) { - fixed (byte* bPtr = source) + fixed (byte* bPtr = &MemoryMarshal.GetReference(source)) { return encoding.GetString(bPtr, source.Length); } From 89e1a3db4588ddf1ce7dd5eb1e65223f9c662da4 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 24 Apr 2026 14:32:15 +0100 Subject: [PATCH 12/14] fix cluster routing of subkey notifications; assert newline logic; use deterministic tests (estimate was causing flakiness) --- src/StackExchange.Redis/RedisChannel.cs | 51 +++ .../PubSubKeyNotificationTests.cs | 342 +++++++++++++++++- 2 files changed, 385 insertions(+), 8 deletions(-) diff --git a/src/StackExchange.Redis/RedisChannel.cs b/src/StackExchange.Redis/RedisChannel.cs index f670c8055..4f2eda7c3 100644 --- a/src/StackExchange.Redis/RedisChannel.cs +++ b/src/StackExchange.Redis/RedisChannel.cs @@ -41,6 +41,43 @@ internal static ReadOnlySpan StripKeySpacePrefix(ReadOnlySpan span) int end = subspan.IndexOf("__:"u8); if (end >= 0) return subspan.Slice(end + 3); } + else if (span.Length >= 19 && span.StartsWith("__subkeyspace@"u8)) + { + // Format: __subkeyspace@{db}__:{key} + // We want to extract {key} for routing + var subspan = span.Slice(14); // Skip "__subkeyspace@" + int end = subspan.IndexOf("__:"u8); + if (end >= 0) return subspan.Slice(end + 3); + } + else if (span.Length >= 20 && span.StartsWith("__subkeyspaceitem@"u8)) + { + // Format: __subkeyspaceitem@{db}__:{key}\n{field} + // We want to extract {key} for routing + var subspan = span.Slice(18); // Skip "__subkeyspaceitem@" + int end = subspan.IndexOf("__:"u8); + if (end >= 0) + { + subspan = subspan.Slice(end + 3); // Skip "{db}__:" + // Find the newline that separates key from field + int newline = subspan.IndexOf((byte)'\n'); + if (newline >= 0) return subspan.Slice(0, newline); // Return just the key + return subspan; // No newline found, return rest + } + } + else if (span.Length >= 23 && span.StartsWith("__subkeyspaceevent@"u8)) + { + // Format: __subkeyspaceevent@{db}__:{event}|{key} + // We want to extract {key} for routing + var subspan = span.Slice(19); // Skip "__subkeyspaceevent@" + int end = subspan.IndexOf("__:"u8); + if (end >= 0) + { + subspan = subspan.Slice(end + 3); // Skip "{db}__:" + // Find the pipe that separates event from key + int pipe = subspan.IndexOf((byte)'|'); + if (pipe >= 0) return subspan.Slice(pipe + 1); // Return just the key + } + } return span; } @@ -365,6 +402,14 @@ public static RedisChannel SubKeySpaceItem(in RedisKey key, in RedisKey subkey, if (key.IsEmpty) throw new ArgumentNullException(nameof(key)); if (subkey.IsEmpty) throw new ArgumentNullException(nameof(subkey)); + // Redis does not issue notifications for keys containing \n to avoid ambiguity in SubKeySpaceItem format + // Check by converting to string and looking for \n + var keyString = key.ToString(); + if (keyString?.IndexOf('\n') >= 0) + { + throw new ArgumentException("Keys containing newline characters are not supported for SubKeySpaceItem notifications", nameof(key)); + } + var db = AppendDatabase(stackalloc byte[DatabaseScratchBufferSize], database, RedisChannelOptions.None); // __subkeyspaceitem@{db}__:{key}\n{subkey} @@ -405,6 +450,12 @@ public static RedisChannel SubKeySpaceEvent(ReadOnlySpan type, in RedisKey if (type.IsEmpty) throw new ArgumentNullException(nameof(type)); if (key.IsEmpty) throw new ArgumentNullException(nameof(key)); + // Redis rejects events containing | to avoid ambiguity in SubKeySpaceEvent format + if (type.IndexOf((byte)'|') >= 0) + { + throw new ArgumentException("Event types containing pipe characters are not supported for SubKeySpaceEvent notifications", nameof(type)); + } + RedisChannelOptions options = RedisChannelOptions.MultiNode; if (database is null) options |= RedisChannelOptions.Pattern; var db = AppendDatabase(stackalloc byte[DatabaseScratchBufferSize], database, options); diff --git a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs index ee5229a99..75ee4f9b4 100644 --- a/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs +++ b/tests/StackExchange.Redis.Tests/PubSubKeyNotificationTests.cs @@ -624,13 +624,22 @@ public async Task SubKeySpaceItem_CanObserveSpecificHashField(bool withChannelPr Assert.False(channel.IsPattern); Log($"Monitoring channel: {channel}"); + // Use seeded random to get deterministic field selection + int seed = SharedRandom.Next(); + var countRandom = new Random(seed); + int expectedCount = 0; + for (int i = 0; i < DefaultEventCount; i++) + { + var field = fields[countRandom.Next(0, fields.Length)]; + if (field == targetField) expectedCount++; + } + Log($"Using seed {seed}, expecting exactly {expectedCount} hits on '{targetField}' out of {DefaultEventCount} operations"); + var sub = conn.GetSubscriber(); await sub.UnsubscribeAsync(channel); Counter callbackCount = new(), targetFieldCount = new(); TaskCompletionSource allDone = new(); - int expectedCount = (DefaultEventCount / fields.Length) + 2; // Approximate expected hits for target field - await sub.SubscribeAsync(channel, (recvChannel, recvValue) => { callbackCount.Increment(); @@ -643,7 +652,7 @@ await sub.SubscribeAsync(channel, (recvChannel, recvValue) => var fieldName = subKey.ToString(); Assert.Equal(targetField, fieldName); // Should only observe the specific field targetFieldCount.Increment(); - Log($"Observed target field: '{fieldName}' ({targetFieldCount.Count} times)"); + Log($"Observed target field: '{fieldName}' ({targetFieldCount.Count} of exactly {expectedCount} times)"); if (targetFieldCount.Count >= expectedCount) { @@ -652,17 +661,27 @@ await sub.SubscribeAsync(channel, (recvChannel, recvValue) => } }); - await Task.Delay(300).ForAwait(); // give it a moment to settle + // Verify subscription is active by doing a test operation on a DIFFERENT field + // This ensures subscription is ready without affecting the target field count + Log("Verifying subscription is active..."); + var testField = "test_field_verify"; + await db.HashSetAsync(hashKey, testField, "test"); + await Task.Delay(300).ForAwait(); // Give subscription time to activate - // Set various fields, but only targetField should trigger notifications + Log($"Subscription verified. Starting {DefaultEventCount} HSET operations, expecting exactly {expectedCount} notifications for '{targetField}'"); + + // Set various fields using the same seeded random, but only targetField should trigger notifications + var operationRandom = new Random(seed); for (int i = 0; i < DefaultEventCount; i++) { - var field = fields[SharedRandom.Next(0, fields.Length)]; + var field = fields[operationRandom.Next(0, fields.Length)]; await db.HashSetAsync(hashKey, field, i); } - Assert.True(await allDone.Task.WithTimeout(5000)); - Assert.True(targetFieldCount.Count >= expectedCount / 2); // At least some hits on target field + Log($"Completed all HSET operations, waiting for notifications..."); + Assert.True(await allDone.Task.WithTimeout(5000), $"Timed out waiting for notifications. Received {targetFieldCount.Count} of expected {expectedCount}"); + Assert.Equal(expectedCount, targetFieldCount.Count); // Exact match since we used seeded random + Log($"Test completed successfully with {targetFieldCount.Count} notifications (exactly as expected)"); await sub.UnsubscribeAsync(channel); } @@ -779,4 +798,311 @@ await sub.SubscribeAsync(channel, (recvChannel, recvValue) => await sub.UnsubscribeAsync(channel); } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SubKeyEvent_HandlesNewlineInKey(bool withChannelPrefix) + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeyEvent, withChannelPrefix); + var db = conn.GetDatabase(); + + var hashKey = $"{Guid.NewGuid()}/key\nwith\nnewlines"; + var fields = new[] { "field1", "field2" }; + var channel = RedisChannel.SubKeyEvent(KeyNotificationType.HSet, db.Database); + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + + Counter callbackCount = new(); + HashSet observedKeys = new(); + TaskCompletionSource allDone = new(); + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + callbackCount.Increment(); + Log($"SubKeyEvent_HandlesNewlineInKey: Received on '{recvChannel}', value: '{recvValue}'"); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeyEvent + && notification.Type == KeyNotificationType.HSet) + { + var key = notification.GetKey().ToString(); + Log($" Parsed key: '{key}'"); + observedKeys.Add(key!); + if (key == hashKey) allDone.TrySetResult(true); + } + }); + + await db.HashSetAsync(hashKey, fields[0], "value1"); + + Assert.True(await allDone.Task.WithTimeout(5000), "Did not receive notification for key with newlines"); + Assert.Contains(hashKey, observedKeys); + + await sub.UnsubscribeAsync(channel); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SubKeyEvent_HandlesNewlineInField(bool withChannelPrefix) + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeyEvent, withChannelPrefix); + var db = conn.GetDatabase(); + + var hashKey = $"{Guid.NewGuid()}/hash"; + var fieldWithNewline = "field\nwith\nnewlines"; + var channel = RedisChannel.SubKeyEvent(KeyNotificationType.HSet, db.Database); + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + + Counter callbackCount = new(); + HashSet observedFields = new(); + TaskCompletionSource allDone = new(); + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + callbackCount.Increment(); + Log($"SubKeyEvent_HandlesNewlineInField: Received on '{recvChannel}', value: '{recvValue}'"); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeyEvent + && notification.Type == KeyNotificationType.HSet + && notification.GetKey() == hashKey) + { + var subKeys = notification.GetSubKeys(); + foreach (var subKey in subKeys) + { + var field = subKey.ToString(); + Log($" Parsed field: '{field}'"); + observedFields.Add(field!); + if (field == fieldWithNewline) allDone.TrySetResult(true); + } + } + }); + + await db.HashSetAsync(hashKey, fieldWithNewline, "value1"); + + Assert.True(await allDone.Task.WithTimeout(5000), "Did not receive notification for field with newlines"); + Assert.Contains(fieldWithNewline, observedFields); + + await sub.UnsubscribeAsync(channel); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SubKeySpace_HandlesNewlineInKey(bool withChannelPrefix) + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeySpace, withChannelPrefix); + var db = conn.GetDatabase(); + + var hashKey = $"{Guid.NewGuid()}/key\nwith\nnewlines"; + var field = "field1"; + var channel = RedisChannel.SubKeySpaceSingleKey(hashKey, db.Database); + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + + Counter receivedCount = new(); + TaskCompletionSource allDone = new(); + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + receivedCount.Increment(); + Log($"SubKeySpace_HandlesNewlineInKey: Received on '{recvChannel}', value: '{recvValue}'"); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeySpace + && notification.Type == KeyNotificationType.HSet) + { + Log($" Parsed successfully, Type={notification.Type}"); + allDone.TrySetResult(true); + } + }); + + await db.HashSetAsync(hashKey, field, "value1"); + + Assert.True(await allDone.Task.WithTimeout(5000), "Did not receive notification for key with newlines"); + + await sub.UnsubscribeAsync(channel); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SubKeySpace_HandlesNewlineInField(bool withChannelPrefix) + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeySpace, withChannelPrefix); + var db = conn.GetDatabase(); + + var hashKey = $"{Guid.NewGuid()}/hash"; + var fieldWithNewline = "field\nwith\nnewlines"; + var channel = RedisChannel.SubKeySpaceSingleKey(hashKey, db.Database); + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + + HashSet observedFields = new(); + TaskCompletionSource allDone = new(); + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + Log($"SubKeySpace_HandlesNewlineInField: Received on '{recvChannel}', value: '{recvValue}'"); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeySpace + && notification.Type == KeyNotificationType.HSet) + { + Log($" Parsed successfully, Type={notification.Type}, Key='{notification.GetKey()}'"); + var subKeys = notification.GetSubKeys(); + foreach (var subKey in subKeys) + { + var field = subKey.ToString(); + Log($" Parsed field: '{field}'"); + observedFields.Add(field!); + if (field == fieldWithNewline) allDone.TrySetResult(true); + } + } + }); + + await db.HashSetAsync(hashKey, fieldWithNewline, "value1"); + + Assert.True(await allDone.Task.WithTimeout(5000), "Did not receive notification for field with newlines"); + Assert.Contains(fieldWithNewline, observedFields); + + await sub.UnsubscribeAsync(channel); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SubKeySpaceEvent_HandlesNewlineInKey(bool withChannelPrefix) + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeySpaceEvent, withChannelPrefix); + var db = conn.GetDatabase(); + + var hashKey = $"{Guid.NewGuid()}/key\nwith\nnewlines"; + var field = "field1"; + var channel = RedisChannel.SubKeySpaceEvent(KeyNotificationType.HSet, hashKey, db.Database); + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + + Counter receivedCount = new(); + TaskCompletionSource allDone = new(); + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + receivedCount.Increment(); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeySpaceEvent + && notification.Type == KeyNotificationType.HSet) + { + allDone.TrySetResult(true); + } + }); + + await db.HashSetAsync(hashKey, field, "value1"); + + Assert.True(await allDone.Task.WithTimeout(5000), "Did not receive notification for key with newlines"); + + await sub.UnsubscribeAsync(channel); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SubKeySpaceEvent_HandlesNewlineInField(bool withChannelPrefix) + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeySpaceEvent, withChannelPrefix); + var db = conn.GetDatabase(); + + var hashKey = $"{Guid.NewGuid()}/hash"; + var fieldWithNewline = "field\nwith\nnewlines"; + var channel = RedisChannel.SubKeySpaceEvent(KeyNotificationType.HSet, hashKey, db.Database); + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + + HashSet observedFields = new(); + TaskCompletionSource allDone = new(); + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeySpaceEvent + && notification.Type == KeyNotificationType.HSet) + { + var subKeys = notification.GetSubKeys(); + foreach (var subKey in subKeys) + { + var field = subKey.ToString(); + observedFields.Add(field!); + if (field == fieldWithNewline) allDone.TrySetResult(true); + } + } + }); + + await db.HashSetAsync(hashKey, fieldWithNewline, "value1"); + + Assert.True(await allDone.Task.WithTimeout(5000), "Did not receive notification for field with newlines"); + Assert.Contains(fieldWithNewline, observedFields); + + await sub.UnsubscribeAsync(channel); + } + + [Fact] + public async Task SubKeySpaceItem_HandlesNewlineInField() + { + await using var conn = await ConnectAsync(KeyNotificationKind.SubKeySpaceItem, withChannelPrefix: false); + var db = conn.GetDatabase(); + + var hashKey = $"{Guid.NewGuid()}/hash"; + var fieldWithNewline = "field\nwith\nnewlines"; + var channel = RedisChannel.SubKeySpaceItem(hashKey, fieldWithNewline, db.Database); + var sub = conn.GetSubscriber(); + await sub.UnsubscribeAsync(channel); + + Counter receivedCount = new(); + TaskCompletionSource allDone = new(); + + await sub.SubscribeAsync(channel, (recvChannel, recvValue) => + { + receivedCount.Increment(); + if (KeyNotification.TryParse(in recvChannel, in recvValue, out var notification) + && notification.Kind == KeyNotificationKind.SubKeySpaceItem + && notification.Type == KeyNotificationType.HSet) + { + var subKey = notification.GetSubKeys().FirstOrDefault(); + if (subKey.ToString() == fieldWithNewline) + { + allDone.TrySetResult(true); + } + } + }); + + await db.HashSetAsync(hashKey, fieldWithNewline, "value1"); + + Assert.True(await allDone.Task.WithTimeout(5000), "Did not receive notification for field with newlines"); + + await sub.UnsubscribeAsync(channel); + } + + [Fact] + public void SubKeySpaceItem_RejectsKeyWithNewline() + { + var keyWithNewline = (RedisKey)"key\nwith\nnewlines"; + var field = (RedisKey)"field1"; + + var ex = Assert.Throws(() => + RedisChannel.SubKeySpaceItem(keyWithNewline, field, 0)); + + Assert.Contains("newline", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal("key", ex.ParamName); + } + + [Fact] + public void SubKeySpaceEvent_RejectsEventWithPipe() + { + byte[] eventWithPipe = "event|with|pipes"u8.ToArray(); + var key = (RedisKey)"mykey"; + + var ex = Assert.Throws(() => + RedisChannel.SubKeySpaceEvent(eventWithPipe, key, 0)); + + Assert.Contains("pipe", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal("type", ex.ParamName); + } } From 60405daae20ec7ba952b4c540ebe0c3eaebf4f90 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 24 Apr 2026 15:33:08 +0100 Subject: [PATCH 13/14] ignore codex files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c0024fb1f..b59d46676 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ launchSettings.json *.diagsession TestResults/ BenchmarkDotNet.Artifacts/ +.codex From a41035178a5a9a9afb3533c11075b0304806e58b Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 24 Apr 2026 16:26:10 +0100 Subject: [PATCH 14/14] experimental efficiency API --- .../KeyNotification.SubKeys.cs | 121 +++++++++++++++--- .../PublicAPI/PublicAPI.Unshipped.txt | 6 + .../KeyNotificationUnitTests.cs | 48 +++++++ 3 files changed, 158 insertions(+), 17 deletions(-) diff --git a/src/StackExchange.Redis/KeyNotification.SubKeys.cs b/src/StackExchange.Redis/KeyNotification.SubKeys.cs index eef3684c5..31e653f2a 100644 --- a/src/StackExchange.Redis/KeyNotification.SubKeys.cs +++ b/src/StackExchange.Redis/KeyNotification.SubKeys.cs @@ -1,9 +1,11 @@ using System; using System.Buffers; +using System.Buffers.Text; using System.Collections; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Text; using RESPite; namespace StackExchange.Redis; @@ -190,6 +192,9 @@ public ref struct SubKeyEnumerator private ReadOnlySpan _data; private byte[]? _lease; private int _position; + private int _currentOffset; + private int _currentLength; + private bool _hasCurrent; private RedisValue _current; internal SubKeyEnumerator(scoped KeyNotification notification) @@ -197,6 +202,9 @@ internal SubKeyEnumerator(scoped KeyNotification notification) _kind = notification._kind; _lease = null; _position = 0; + _currentOffset = 0; + _currentLength = 0; + _hasCurrent = false; _current = default; // Always copy the relevant data to a leased buffer to avoid lifetime issues @@ -265,10 +273,74 @@ private static ReadOnlySpan CopyAndLeaseValue(RedisValue value, out byte[] return buffer.AsSpan(0, written); } + private ReadOnlySpan CurrentBytes => _hasCurrent ? _data.Slice(_currentOffset, _currentLength) : default; + /// /// Gets the current sub-key. /// - public RedisValue Current => _current; + public RedisValue Current + { + get + { + if (!_hasCurrent) return default; + if (_current.IsNull) + { + _current = CurrentBytes.ToArray(); + } + return _current; + } + } + + /// + /// Gets the raw bytes of the current sub-key. + /// + public ReadOnlySpan CurrentSpan => CurrentBytes; + + /// + /// Gets the byte length of the current sub-key. + /// + public int CurrentByteCount => _hasCurrent ? _currentLength : 0; + + /// + /// Gets the maximum number of UTF-8 characters in the current sub-key. + /// + public int CurrentMaxCharCount => _hasCurrent ? Encoding.UTF8.GetMaxCharCount(_currentLength) : 0; + + /// + /// Gets the actual number of UTF-8 characters in the current sub-key. + /// + public int GetCurrentCharCount() => _hasCurrent ? Encoding.UTF8.GetCharCount(CurrentBytes) : 0; + + /// + /// Attempts to copy the current sub-key bytes into the destination span. + /// + public bool TryCopyTo(scoped Span destination, out int bytesWritten) + { + var span = CurrentBytes; + bytesWritten = span.Length; + if (bytesWritten <= destination.Length) + { + span.CopyTo(destination); + return true; + } + return false; + } + + /// + /// Attempts to copy the current sub-key as UTF-8 characters into the destination span. + /// + public bool TryCopyTo(scoped Span destination, out int charsWritten) + { + var span = CurrentBytes; + if (Encoding.UTF8.GetMaxCharCount(span.Length) <= destination.Length || + Encoding.UTF8.GetCharCount(span) <= destination.Length) + { + charsWritten = Encoding.UTF8.GetChars(span, destination); + return true; + } + charsWritten = 0; + return false; + } /// /// Advances to the next sub-key. @@ -277,6 +349,11 @@ private static ReadOnlySpan CopyAndLeaseValue(RedisValue value, out byte[] internal bool TryMoveNext(bool setCurrent) { + _hasCurrent = false; + _current = default; + _currentOffset = 0; + _currentLength = 0; + if (_position >= _data.Length) { return false; @@ -288,10 +365,9 @@ internal bool TryMoveNext(bool setCurrent) // Single subkey - return it once if (_position == 0) { - if (setCurrent) - { - _current = _data.ToArray(); - } + _hasCurrent = true; + _currentLength = _data.Length; + if (setCurrent) _ = Current; _position = _data.Length; // Mark as consumed return true; } @@ -301,24 +377,19 @@ internal bool TryMoveNext(bool setCurrent) case KeyNotificationKind.SubKeyEvent: case KeyNotificationKind.SubKeySpaceEvent: // Length-prefixed format: : - var value = KeyNotification.ExtractLengthPrefixedValue(_data.Slice(_position)); - if (value.IsNull) + var remaining = _data.Slice(_position); + if (!TryGetLengthPrefixedRange(remaining, out var valueOffset, out var valueLength)) { return false; } - if (setCurrent) - { - _current = value; - } + _hasCurrent = true; + _currentOffset = _position + valueOffset; + _currentLength = valueLength; + if (setCurrent) _ = Current; // Move position forward: skip the length prefix + colon + value + pipe (if present) - var remaining = _data.Slice(_position); - var colonIndex = remaining.IndexOf((byte)':'); - if (colonIndex < 0) return false; - - var valueLength = (int)value.Length(); - _position += colonIndex + 1 + valueLength; + _position += valueOffset + valueLength; // Skip the separator if present (| or ,) if (_position < _data.Length && (_data[_position] == (byte)'|' || _data[_position] == (byte)',')) @@ -344,5 +415,21 @@ public void Dispose() _lease = null; } } + + private static bool TryGetLengthPrefixedRange(ReadOnlySpan span, out int valueOffset, out int valueLength) + { + var colonIndex = span.IndexOf((byte)':'); + if (colonIndex > 0 && Utf8Parser.TryParse(span.Slice(0, colonIndex), out int length, out _)) + { + valueOffset = colonIndex + 1; + if (valueOffset + length <= span.Length) + { + valueLength = length; + return true; + } + } + valueOffset = valueLength = 0; + return false; + } } } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index f94217cd1..57486c144 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -23,9 +23,15 @@ [SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerable.TryCopyTo(System.Span destination, out int count) -> bool [SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator [SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.SubKeyEnumerator() -> void +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.CurrentByteCount.get -> int [SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.Current.get -> StackExchange.Redis.RedisValue +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.CurrentMaxCharCount.get -> int +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.CurrentSpan.get -> System.ReadOnlySpan [SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.Dispose() -> void +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.GetCurrentCharCount() -> int [SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.MoveNext() -> bool +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.TryCopyTo(scoped System.Span destination, out int bytesWritten) -> bool +[SER006]StackExchange.Redis.KeyNotification.SubKeyEnumerator.TryCopyTo(scoped System.Span destination, out int charsWritten) -> bool StackExchange.Redis.KeyNotification.HasSubKey.get -> bool StackExchange.Redis.KeyNotification.Kind.get -> StackExchange.Redis.KeyNotificationKind StackExchange.Redis.KeyNotificationKind diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs index 8c4b27523..eba9d9e4b 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs @@ -1169,6 +1169,54 @@ public void GetSubKeys_ToList_ReturnsList() Assert.Equal("field3", (string?)list[2]); } + [Fact] + public void GetSubKeys_Enumerator_CurrentSpanAndCopy_WorksWithoutCurrent() + { + var channel = RedisChannel.Literal("__subkeyspace@4__:mykey"); + RedisValue value = "hset|6:field1|6:field2"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + using var enumerator = notification.GetSubKeys().GetEnumerator(); + Assert.True(enumerator.MoveNext()); + + Assert.Equal(6, enumerator.CurrentByteCount); + Assert.Equal("field1"u8.ToArray(), enumerator.CurrentSpan.ToArray()); + Assert.Equal(6, enumerator.GetCurrentCharCount()); + + Span byteBuffer = stackalloc byte[16]; + Assert.True(enumerator.TryCopyTo(byteBuffer, out var bytesWritten)); + Assert.Equal(6, bytesWritten); + Assert.Equal("field1", Encoding.UTF8.GetString(byteBuffer.Slice(0, bytesWritten))); + + Span charBuffer = stackalloc char[16]; + Assert.True(enumerator.TryCopyTo(charBuffer, out var charsWritten)); + Assert.Equal(6, charsWritten); + Assert.Equal("field1", charBuffer.Slice(0, charsWritten).ToString()); + + Assert.True(enumerator.MoveNext()); + Assert.Equal("field2"u8.ToArray(), enumerator.CurrentSpan.ToArray()); + } + + [Fact] + public void GetSubKeys_Enumerator_CurrentSurvivesDispose() + { + var channel = RedisChannel.Literal("__subkeyspaceevent@4__:hset|mykey"); + RedisValue value = "6:field1|6:field2"; + + Assert.True(KeyNotification.TryParse(channel, value, out var notification)); + + RedisValue first; + using (var enumerator = notification.GetSubKeys().GetEnumerator()) + { + Assert.True(enumerator.MoveNext()); + first = enumerator.Current; + Assert.Equal("field1", (string?)enumerator.Current); + } + + Assert.Equal("field1", (string?)first); + } + [Fact] public void ExtractLengthPrefixedValue_ParsesCorrectly() {